From 26ec1e8c155b98e8a220fe1100d47ab5d783b948 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 30 Nov 2022 02:47:42 +0530 Subject: [PATCH 001/104] build: merge frontend and backend into a single repo --- .gitignore | 24 +- apiserver/manage.py | 17 + apiserver/plane/__init__.py | 0 apiserver/plane/analytics/__init__.py | 0 apiserver/plane/analytics/apps.py | 5 + apiserver/plane/api/__init__.py | 0 apiserver/plane/api/apps.py | 5 + apiserver/plane/api/permissions/__init__.py | 2 + apiserver/plane/api/permissions/project.py | 63 ++ apiserver/plane/api/permissions/workspace.py | 43 ++ apiserver/plane/api/serializers/__init__.py | 40 + apiserver/plane/api/serializers/asset.py | 14 + apiserver/plane/api/serializers/base.py | 5 + apiserver/plane/api/serializers/cycle.py | 33 + apiserver/plane/api/serializers/issue.py | 324 ++++++++ apiserver/plane/api/serializers/people.py | 57 ++ apiserver/plane/api/serializers/project.py | 104 +++ apiserver/plane/api/serializers/shortcut.py | 14 + apiserver/plane/api/serializers/state.py | 14 + apiserver/plane/api/serializers/user.py | 40 + apiserver/plane/api/serializers/view.py | 14 + apiserver/plane/api/serializers/workspace.py | 100 +++ apiserver/plane/api/urls.py | 584 +++++++++++++++ apiserver/plane/api/views/__init__.py | 66 ++ apiserver/plane/api/views/asset.py | 30 + apiserver/plane/api/views/auth_extended.py | 159 ++++ apiserver/plane/api/views/authentication.py | 299 ++++++++ apiserver/plane/api/views/base.py | 142 ++++ apiserver/plane/api/views/cycle.py | 109 +++ apiserver/plane/api/views/issue.py | 394 ++++++++++ apiserver/plane/api/views/oauth.py | 269 +++++++ apiserver/plane/api/views/people.py | 76 ++ apiserver/plane/api/views/project.py | 526 +++++++++++++ apiserver/plane/api/views/shortcut.py | 29 + apiserver/plane/api/views/state.py | 29 + apiserver/plane/api/views/view.py | 29 + apiserver/plane/api/views/workspace.py | 462 ++++++++++++ apiserver/plane/bgtasks/__init__.py | 0 apiserver/plane/bgtasks/apps.py | 5 + apiserver/plane/bgtasks/celery.py | 0 .../plane/bgtasks/email_verification_task.py | 40 + .../plane/bgtasks/forgot_password_task.py | 40 + .../plane/bgtasks/magic_link_code_task.py | 35 + .../plane/bgtasks/project_invitation_task.py | 54 ++ .../bgtasks/workspace_invitation_task.py | 57 ++ apiserver/plane/db/__init__.py | 0 apiserver/plane/db/admin.py | 35 + apiserver/plane/db/apps.py | 52 ++ apiserver/plane/db/migrations/0001_initial.py | 704 ++++++++++++++++++ .../db/migrations/0002_auto_20221104_2239.py | 54 ++ .../db/migrations/0003_auto_20221109_2320.py | 24 + .../migrations/0004_alter_state_sequence.py | 18 + .../db/migrations/0005_auto_20221114_2127.py | 23 + .../db/migrations/0006_alter_cycle_status.py | 18 + .../plane/db/migrations/0007_label_parent.py | 19 + .../plane/db/migrations/0008_label_colour.py | 18 + apiserver/plane/db/migrations/__init__.py | 0 apiserver/plane/db/mixins.py | 46 ++ apiserver/plane/db/models/__init__.py | 38 + apiserver/plane/db/models/asset.py | 24 + apiserver/plane/db/models/base.py | 39 + apiserver/plane/db/models/cycle.py | 61 ++ apiserver/plane/db/models/issue.py | 296 ++++++++ apiserver/plane/db/models/project.py | 142 ++++ apiserver/plane/db/models/shortcut.py | 26 + .../plane/db/models/social_connection.py | 34 + apiserver/plane/db/models/state.py | 29 + apiserver/plane/db/models/user.py | 126 ++++ apiserver/plane/db/models/view.py | 22 + apiserver/plane/db/models/workspace.py | 134 ++++ apiserver/plane/middleware/__init__.py | 0 apiserver/plane/middleware/apps.py | 5 + apiserver/plane/middleware/user_middleware.py | 33 + apiserver/plane/settings/__init__.py | 0 apiserver/plane/settings/common.py | 208 ++++++ apiserver/plane/settings/local.py | 67 ++ apiserver/plane/settings/production.py | 188 +++++ apiserver/plane/settings/redis.py | 23 + apiserver/plane/settings/staging.py | 188 +++++ apiserver/plane/settings/test.py | 45 ++ apiserver/plane/static/css/style.css | 0 apiserver/plane/static/humans.txt | 0 apiserver/plane/static/js/script.js | 0 apiserver/plane/tests/__init__.py | 1 + apiserver/plane/tests/api/__init__.py | 0 apiserver/plane/tests/api/base.py | 34 + apiserver/plane/tests/api/test_asset.py | 1 + .../plane/tests/api/test_auth_extended.py | 1 + .../plane/tests/api/test_authentication.py | 209 ++++++ apiserver/plane/tests/api/test_cycle.py | 1 + apiserver/plane/tests/api/test_issue.py | 1 + apiserver/plane/tests/api/test_oauth.py | 1 + apiserver/plane/tests/api/test_people.py | 1 + apiserver/plane/tests/api/test_project.py | 1 + apiserver/plane/tests/api/test_shortcut.py | 1 + apiserver/plane/tests/api/test_state.py | 1 + apiserver/plane/tests/api/test_view.py | 1 + apiserver/plane/tests/api/test_workspace.py | 43 ++ apiserver/plane/tests/apps.py | 5 + apiserver/plane/urls.py | 29 + apiserver/plane/utils/__init__.py | 0 apiserver/plane/utils/imports.py | 20 + apiserver/plane/utils/ip_address.py | 7 + apiserver/plane/utils/markdown.py | 3 + apiserver/plane/utils/paginator.py | 227 ++++++ apiserver/plane/web/__init__.py | 0 apiserver/plane/web/apps.py | 5 + apiserver/plane/web/urls.py | 7 + apiserver/plane/web/views.py | 3 + apiserver/plane/wsgi.py | 15 + apiserver/requirements.txt | 3 + apiserver/requirements/base.txt | 29 + apiserver/requirements/local.txt | 3 + apiserver/requirements/production.txt | 10 + apiserver/requirements/test.txt | 4 + apiserver/templates/about.html | 9 + apiserver/templates/admin/base_site.html | 23 + apiserver/templates/base.html | 20 + .../emails/auth/email_verification.html | 11 + .../emails/auth/forgot_password.html | 11 + .../templates/emails/auth/magic_signin.html | 11 + .../emails/auth/user_welcome_email.html | 407 ++++++++++ .../invitations/project_invitation.html | 13 + .../invitations/workspace_invitation.html | 12 + apiserver/templates/index.html | 5 + runtime.txt | 1 + 126 files changed, 8280 insertions(+), 1 deletion(-) create mode 100644 apiserver/manage.py create mode 100644 apiserver/plane/__init__.py create mode 100644 apiserver/plane/analytics/__init__.py create mode 100644 apiserver/plane/analytics/apps.py create mode 100644 apiserver/plane/api/__init__.py create mode 100644 apiserver/plane/api/apps.py create mode 100644 apiserver/plane/api/permissions/__init__.py create mode 100644 apiserver/plane/api/permissions/project.py create mode 100644 apiserver/plane/api/permissions/workspace.py create mode 100644 apiserver/plane/api/serializers/__init__.py create mode 100644 apiserver/plane/api/serializers/asset.py create mode 100644 apiserver/plane/api/serializers/base.py create mode 100644 apiserver/plane/api/serializers/cycle.py create mode 100644 apiserver/plane/api/serializers/issue.py create mode 100644 apiserver/plane/api/serializers/people.py create mode 100644 apiserver/plane/api/serializers/project.py create mode 100644 apiserver/plane/api/serializers/shortcut.py create mode 100644 apiserver/plane/api/serializers/state.py create mode 100644 apiserver/plane/api/serializers/user.py create mode 100644 apiserver/plane/api/serializers/view.py create mode 100644 apiserver/plane/api/serializers/workspace.py create mode 100644 apiserver/plane/api/urls.py create mode 100644 apiserver/plane/api/views/__init__.py create mode 100644 apiserver/plane/api/views/asset.py create mode 100644 apiserver/plane/api/views/auth_extended.py create mode 100644 apiserver/plane/api/views/authentication.py create mode 100644 apiserver/plane/api/views/base.py create mode 100644 apiserver/plane/api/views/cycle.py create mode 100644 apiserver/plane/api/views/issue.py create mode 100644 apiserver/plane/api/views/oauth.py create mode 100644 apiserver/plane/api/views/people.py create mode 100644 apiserver/plane/api/views/project.py create mode 100644 apiserver/plane/api/views/shortcut.py create mode 100644 apiserver/plane/api/views/state.py create mode 100644 apiserver/plane/api/views/view.py create mode 100644 apiserver/plane/api/views/workspace.py create mode 100644 apiserver/plane/bgtasks/__init__.py create mode 100644 apiserver/plane/bgtasks/apps.py create mode 100644 apiserver/plane/bgtasks/celery.py create mode 100644 apiserver/plane/bgtasks/email_verification_task.py create mode 100644 apiserver/plane/bgtasks/forgot_password_task.py create mode 100644 apiserver/plane/bgtasks/magic_link_code_task.py create mode 100644 apiserver/plane/bgtasks/project_invitation_task.py create mode 100644 apiserver/plane/bgtasks/workspace_invitation_task.py create mode 100644 apiserver/plane/db/__init__.py create mode 100644 apiserver/plane/db/admin.py create mode 100644 apiserver/plane/db/apps.py create mode 100644 apiserver/plane/db/migrations/0001_initial.py create mode 100644 apiserver/plane/db/migrations/0002_auto_20221104_2239.py create mode 100644 apiserver/plane/db/migrations/0003_auto_20221109_2320.py create mode 100644 apiserver/plane/db/migrations/0004_alter_state_sequence.py create mode 100644 apiserver/plane/db/migrations/0005_auto_20221114_2127.py create mode 100644 apiserver/plane/db/migrations/0006_alter_cycle_status.py create mode 100644 apiserver/plane/db/migrations/0007_label_parent.py create mode 100644 apiserver/plane/db/migrations/0008_label_colour.py create mode 100644 apiserver/plane/db/migrations/__init__.py create mode 100644 apiserver/plane/db/mixins.py create mode 100644 apiserver/plane/db/models/__init__.py create mode 100644 apiserver/plane/db/models/asset.py create mode 100644 apiserver/plane/db/models/base.py create mode 100644 apiserver/plane/db/models/cycle.py create mode 100644 apiserver/plane/db/models/issue.py create mode 100644 apiserver/plane/db/models/project.py create mode 100644 apiserver/plane/db/models/shortcut.py create mode 100644 apiserver/plane/db/models/social_connection.py create mode 100644 apiserver/plane/db/models/state.py create mode 100644 apiserver/plane/db/models/user.py create mode 100644 apiserver/plane/db/models/view.py create mode 100644 apiserver/plane/db/models/workspace.py create mode 100644 apiserver/plane/middleware/__init__.py create mode 100644 apiserver/plane/middleware/apps.py create mode 100644 apiserver/plane/middleware/user_middleware.py create mode 100644 apiserver/plane/settings/__init__.py create mode 100644 apiserver/plane/settings/common.py create mode 100644 apiserver/plane/settings/local.py create mode 100644 apiserver/plane/settings/production.py create mode 100644 apiserver/plane/settings/redis.py create mode 100644 apiserver/plane/settings/staging.py create mode 100644 apiserver/plane/settings/test.py create mode 100644 apiserver/plane/static/css/style.css create mode 100644 apiserver/plane/static/humans.txt create mode 100644 apiserver/plane/static/js/script.js create mode 100644 apiserver/plane/tests/__init__.py create mode 100644 apiserver/plane/tests/api/__init__.py create mode 100644 apiserver/plane/tests/api/base.py create mode 100644 apiserver/plane/tests/api/test_asset.py create mode 100644 apiserver/plane/tests/api/test_auth_extended.py create mode 100644 apiserver/plane/tests/api/test_authentication.py create mode 100644 apiserver/plane/tests/api/test_cycle.py create mode 100644 apiserver/plane/tests/api/test_issue.py create mode 100644 apiserver/plane/tests/api/test_oauth.py create mode 100644 apiserver/plane/tests/api/test_people.py create mode 100644 apiserver/plane/tests/api/test_project.py create mode 100644 apiserver/plane/tests/api/test_shortcut.py create mode 100644 apiserver/plane/tests/api/test_state.py create mode 100644 apiserver/plane/tests/api/test_view.py create mode 100644 apiserver/plane/tests/api/test_workspace.py create mode 100644 apiserver/plane/tests/apps.py create mode 100644 apiserver/plane/urls.py create mode 100644 apiserver/plane/utils/__init__.py create mode 100644 apiserver/plane/utils/imports.py create mode 100644 apiserver/plane/utils/ip_address.py create mode 100644 apiserver/plane/utils/markdown.py create mode 100644 apiserver/plane/utils/paginator.py create mode 100644 apiserver/plane/web/__init__.py create mode 100644 apiserver/plane/web/apps.py create mode 100644 apiserver/plane/web/urls.py create mode 100644 apiserver/plane/web/views.py create mode 100644 apiserver/plane/wsgi.py create mode 100644 apiserver/requirements.txt create mode 100644 apiserver/requirements/base.txt create mode 100644 apiserver/requirements/local.txt create mode 100644 apiserver/requirements/production.txt create mode 100644 apiserver/requirements/test.txt create mode 100644 apiserver/templates/about.html create mode 100644 apiserver/templates/admin/base_site.html create mode 100644 apiserver/templates/base.html create mode 100644 apiserver/templates/emails/auth/email_verification.html create mode 100644 apiserver/templates/emails/auth/forgot_password.html create mode 100644 apiserver/templates/emails/auth/magic_signin.html create mode 100644 apiserver/templates/emails/auth/user_welcome_email.html create mode 100644 apiserver/templates/emails/invitations/project_invitation.html create mode 100644 apiserver/templates/emails/invitations/workspace_invitation.html create mode 100644 apiserver/templates/index.html create mode 100644 runtime.txt diff --git a/.gitignore b/.gitignore index dd512696d..ad72521ff 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,26 @@ yarn-error.log* .vercel # Turborepo -.turbo \ No newline at end of file +.turbo + +## Django ## +venv +*.pyc +staticfiles +mediafiles +.env +.DS_Store + +node_modules/ +assets/dist/ +npm-debug.log +yarn-error.log + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +package-lock.json +.vscode diff --git a/apiserver/manage.py b/apiserver/manage.py new file mode 100644 index 000000000..837297219 --- /dev/null +++ b/apiserver/manage.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', + 'plane.settings.production') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/apiserver/plane/__init__.py b/apiserver/plane/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/analytics/__init__.py b/apiserver/plane/analytics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/analytics/apps.py b/apiserver/plane/analytics/apps.py new file mode 100644 index 000000000..353779983 --- /dev/null +++ b/apiserver/plane/analytics/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + name = 'plane.analytics' diff --git a/apiserver/plane/api/__init__.py b/apiserver/plane/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py new file mode 100644 index 000000000..6ba36e7e5 --- /dev/null +++ b/apiserver/plane/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.api" diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py new file mode 100644 index 000000000..71ec4815d --- /dev/null +++ b/apiserver/plane/api/permissions/__init__.py @@ -0,0 +1,2 @@ +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission +from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py new file mode 100644 index 000000000..019496cda --- /dev/null +++ b/apiserver/plane/api/permissions/project.py @@ -0,0 +1,63 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module import +from plane.db.models import WorkspaceMember, ProjectMember + + +class ProjectBasePermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role=20 + ).exists() + + +class ProjectMemberPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + +class ProjectEntityPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py new file mode 100644 index 000000000..510d87ce2 --- /dev/null +++ b/apiserver/plane/api/permissions/workspace.py @@ -0,0 +1,43 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module imports +from plane.db.models import WorkspaceMember, ProjectMember + + +# TODO: Move the below logic to python match - python v3.10 +class WorkSpaceBasePermission(BasePermission): + def has_permission(self, request, view): + # allow anyone to create a workspace + if request.user.is_anonymous: + return False + + if request.method == "POST": + return True + + ## Safe Methods + if request.method in SAFE_METHODS: + return True + + # allow only admins and owners to update the workspace settings + if request.method in ["PUT", "PATCH"]: + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role__in=[15, 20] + ).exists() + + # allow only owner to delete the workspace + if request.method == "DELETE": + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role=20 + ).exists() + + +class WorkSpaceAdminPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role__in=[15, 20] + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py new file mode 100644 index 000000000..abc42cb4b --- /dev/null +++ b/apiserver/plane/api/serializers/__init__.py @@ -0,0 +1,40 @@ +from .base import BaseSerializer +from .people import ( + ChangePasswordSerializer, + ResetPasswordSerializer, + TokenSerializer, +) +from .user import UserSerializer, UserLiteSerializer +from .workspace import ( + WorkSpaceSerializer, + WorkSpaceMemberSerializer, + TeamSerializer, + WorkSpaceMemberInviteSerializer, +) +from .project import ( + ProjectSerializer, + ProjectDetailSerializer, + ProjectMemberSerializer, + ProjectMemberInviteSerializer, + ProjectIdentifierSerializer, +) +from .state import StateSerializer +from .shortcut import ShortCutSerializer +from .view import ViewSerializer +from .cycle import CycleSerializer, CycleIssueSerializer +from .asset import FileAssetSerializer +from .issue import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + TimeLineIssueSerializer, + IssuePropertySerializer, + IssueLabelSerializer, + BlockerIssueSerializer, + BlockedIssueSerializer, + IssueAssigneeSerializer, + LabelSerializer, + IssueSerializer, + IssueFlatSerializer, + IssueStateSerializer, +) diff --git a/apiserver/plane/api/serializers/asset.py b/apiserver/plane/api/serializers/asset.py new file mode 100644 index 000000000..136e2264b --- /dev/null +++ b/apiserver/plane/api/serializers/asset.py @@ -0,0 +1,14 @@ +from .base import BaseSerializer +from plane.db.models import FileAsset + + +class FileAssetSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py new file mode 100644 index 000000000..0c6bba468 --- /dev/null +++ b/apiserver/plane/api/serializers/base.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py new file mode 100644 index 000000000..15706aa99 --- /dev/null +++ b/apiserver/plane/api/serializers/cycle.py @@ -0,0 +1,33 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .issue import IssueStateSerializer +from plane.db.models import Cycle, CycleIssue + + +class CycleSerializer(BaseSerializer): + + owned_by = UserLiteSerializer(read_only=True) + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] + + +class CycleIssueSerializer(BaseSerializer): + + issue_details = IssueStateSerializer(read_only=True, source="issue") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "cycle", + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py new file mode 100644 index 000000000..6677f47cb --- /dev/null +++ b/apiserver/plane/api/serializers/issue.py @@ -0,0 +1,324 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateSerializer +from .user import UserLiteSerializer +from .project import ProjectSerializer +from plane.db.models import ( + User, + Issue, + IssueActivity, + IssueComment, + TimelineIssue, + IssueProperty, + IssueBlocker, + IssueAssignee, + IssueLabel, + Label, + IssueBlocker, +) + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "priority", + "start_date", + "target_date", + ] + + +# Issue Serializer with state details +class IssueStateSerializer(BaseSerializer): + + state_detail = StateSerializer(read_only=True, source="state") + + class Meta: + model = Issue + fields = "__all__" + + +##TODO: Find a better way to write this serializer +## Find a better approach to save manytomany? +class IssueCreateSerializer(BaseSerializer): + + state_detail = StateSerializer(read_only=True, source="state") + + assignees_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + blockers_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), + write_only=True, + required=False, + ) + labels_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data): + blockers = validated_data.pop("blockers_list", None) + assignees = validated_data.pop("assignees_list", None) + labels = validated_data.pop("labels_list", None) + + project = self.context["project"] + issue = Issue.objects.create(**validated_data, project=project) + + if blockers is not None: + IssueBlocker.objects.bulk_create( + [ + IssueBlocker( + block=issue, + blocked_by=blocker, + project=project, + workspace=project.workspace, + created_by=issue.created_by, + updated_by=issue.updated_by, + ) + for blocker in blockers + ], + batch_size=10, + ) + + if assignees is not None: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project=project, + workspace=project.workspace, + created_by=issue.created_by, + updated_by=issue.updated_by, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project=project, + workspace=project.workspace, + created_by=issue.created_by, + updated_by=issue.updated_by, + ) + for label in labels + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + + blockers = validated_data.pop("blockers_list", None) + assignees = validated_data.pop("assignees_list", None) + labels = validated_data.pop("labels_list", None) + + if blockers is not None: + IssueBlocker.objects.filter(block=instance).delete() + IssueBlocker.objects.bulk_create( + [ + IssueBlocker( + block=instance, + blocked_by=blocker, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for blocker in blockers + ], + batch_size=10, + ) + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=instance, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for label in labels + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class IssueActivitySerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueActivity + fields = "__all__" + + +class IssueCommentSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class TimeLineIssueSerializer(BaseSerializer): + class Meta: + model = TimelineIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssuePropertySerializer(BaseSerializer): + class Meta: + model = IssueProperty + fields = "__all__" + read_only_fields = [ + "user", + "workspace", + "project", + ] + + +class LabelSerializer(BaseSerializer): + class Meta: + model = Label + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class IssueLabelSerializer(BaseSerializer): + + # label_details = LabelSerializer(read_only=True, source="label") + + class Meta: + model = IssueLabel + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class BlockedIssueSerializer(BaseSerializer): + + blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True) + + class Meta: + model = IssueBlocker + fields = "__all__" + + +class BlockerIssueSerializer(BaseSerializer): + + blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True) + + class Meta: + model = IssueBlocker + fields = "__all__" + + +class IssueAssigneeSerializer(BaseSerializer): + + assignee_details = UserLiteSerializer(read_only=True, source="assignee") + + class Meta: + model = IssueAssignee + fields = "__all__" + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectSerializer(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) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + blocked_issues = BlockedIssueSerializer(read_only=True, many=True) + blocker_issues = BlockerIssueSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/people.py b/apiserver/plane/api/serializers/people.py new file mode 100644 index 000000000..b8b59416c --- /dev/null +++ b/apiserver/plane/api/serializers/people.py @@ -0,0 +1,57 @@ +from rest_framework.serializers import ( + ModelSerializer, + Serializer, + CharField, + SerializerMethodField, +) +from rest_framework.authtoken.models import Token +from rest_framework_simplejwt.tokens import RefreshToken + + +from plane.db.models import User + + +class UserSerializer(ModelSerializer): + class Meta: + model = User + fields = "__all__" + extra_kwargs = {"password": {"write_only": True}} + + +class ChangePasswordSerializer(Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = CharField(required=True) + new_password = CharField(required=True) + + +class ResetPasswordSerializer(Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + new_password = CharField(required=True) + confirm_password = CharField(required=True) + + +class TokenSerializer(ModelSerializer): + + user = UserSerializer() + access_token = SerializerMethodField() + refresh_token = SerializerMethodField() + + def get_access_token(self, obj): + refresh_token = RefreshToken.for_user(obj.user) + return str(refresh_token.access_token) + + def get_refresh_token(self, obj): + refresh_token = RefreshToken.for_user(obj.user) + return str(refresh_token) + + class Meta: + model = Token + fields = "__all__" diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py new file mode 100644 index 000000000..34afca72b --- /dev/null +++ b/apiserver/plane/api/serializers/project.py @@ -0,0 +1,104 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.api.serializers.workspace import WorkSpaceSerializer +from plane.api.serializers.user import UserLiteSerializer +from plane.db.models import ( + Project, + ProjectMember, + ProjectMemberInvite, + ProjectIdentifier, +) + + +class ProjectSerializer(BaseSerializer): + class Meta: + model = Project + fields = "__all__" + read_only_fields = [ + "workspace", + ] + + def create(self, validated_data): + identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": + raise serializers.ValidationError(detail="Project Identifier is required") + + if ProjectIdentifier.objects.filter(name=identifier).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + project = Project.objects.create( + **validated_data, workspace_id=self.context["workspace_id"] + ) + _ = ProjectIdentifier.objects.create(name=project.identifier, project=project) + return project + + def update(self, instance, validated_data): + + identifier = validated_data.get("identifier", "").strip().upper() + + # If identifier is not passed update the project and return + if identifier == "": + project = super().update(instance, validated_data) + return project + + # If no Project Identifier is found create it + project_identifier = ProjectIdentifier.objects.filter(name=identifier).first() + + if project_identifier is None: + project = super().update(instance, validated_data) + _ = ProjectIdentifier.objects.update(name=identifier, project=project) + return project + + # If found check if the project_id to be updated and identifier project id is same + if project_identifier.project_id == instance.id: + # If same pass update + project = super().update(instance, validated_data) + return project + + # If not same fail update + raise serializers.ValidationError( + detail="Project Identifier is already taken" + ) + + +class ProjectDetailSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + default_assignee = UserLiteSerializer(read_only=True) + project_lead = UserLiteSerializer(read_only=True) + + class Meta: + model = Project + fields = "__all__" + + +class ProjectMemberSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + project = ProjectSerializer(read_only=True) + member = UserLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberInviteSerializer(BaseSerializer): + + project = ProjectSerializer(read_only=True) + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = ProjectMemberInvite + fields = "__all__" + + +class ProjectIdentifierSerializer(BaseSerializer): + class Meta: + model = ProjectIdentifier + fields = "__all__" diff --git a/apiserver/plane/api/serializers/shortcut.py b/apiserver/plane/api/serializers/shortcut.py new file mode 100644 index 000000000..18c2bd049 --- /dev/null +++ b/apiserver/plane/api/serializers/shortcut.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import Shortcut + + +class ShortCutSerializer(BaseSerializer): + class Meta: + model = Shortcut + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py new file mode 100644 index 000000000..6917f8d69 --- /dev/null +++ b/apiserver/plane/api/serializers/state.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import State + + +class StateSerializer(BaseSerializer): + class Meta: + model = State + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py new file mode 100644 index 000000000..808991ddc --- /dev/null +++ b/apiserver/plane/api/serializers/user.py @@ -0,0 +1,40 @@ +# Module import +from .base import BaseSerializer +from plane.db.models import User + + +class UserSerializer(BaseSerializer): + class Meta: + model = User + fields = "__all__" + read_only_fields = [ + "id", + "created_at", + "updated_at", + "is_superuser", + "is_staff", + "last_active", + "last_login_time", + "last_logout_time", + "last_login_ip", + "last_logout_ip", + "last_login_uagent", + "token_updated_at", + "is_onboarded", + ] + extra_kwargs = {"password": {"write_only": True}} + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "email", + "avatar", + ] + read_only_fields = [ + "id", + ] diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py new file mode 100644 index 000000000..23ac768ef --- /dev/null +++ b/apiserver/plane/api/serializers/view.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import View + + +class ViewSerializer(BaseSerializer): + class Meta: + model = View + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py new file mode 100644 index 000000000..15f9b1dbf --- /dev/null +++ b/apiserver/plane/api/serializers/workspace.py @@ -0,0 +1,100 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer + +from plane.db.models import User, Workspace, WorkspaceMember, Team, TeamMember +from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInvite + + +class WorkSpaceSerializer(BaseSerializer): + + owner = UserLiteSerializer(read_only=True) + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "slug", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + ] + extra_kwargs = { + "slug": { + "required": False, + }, + } + + +class WorkSpaceMemberSerializer(BaseSerializer): + + member = UserLiteSerializer(read_only=True) + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkSpaceMemberInviteSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = WorkspaceMemberInvite + fields = "__all__" + + +class TeamSerializer(BaseSerializer): + + members_detail = UserLiteSerializer(read_only=True, source="members", many=True) + members = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Team + fields = "__all__" + read_only_fields = [ + "workspace", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data, **kwargs): + if "members" in validated_data: + members = validated_data.pop("members") + workspace = self.context["workspace"] + team = Team.objects.create(**validated_data, workspace=workspace) + team_members = [ + TeamMember(member=member, team=team, workspace=workspace) + for member in members + ] + TeamMember.objects.bulk_create(team_members, batch_size=10) + return team + else: + team = Team.objects.create(**validated_data) + return team + + def update(self, instance, validated_data): + if "members" in validated_data: + members = validated_data.pop("members") + TeamMember.objects.filter(team=instance).delete() + team_members = [ + TeamMember(member=member, team=instance, workspace=instance.workspace) + for member in members + ] + TeamMember.objects.bulk_create(team_members, batch_size=10) + return super().update(instance, validated_data) + else: + return super().update(instance, validated_data) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py new file mode 100644 index 000000000..560fb78cb --- /dev/null +++ b/apiserver/plane/api/urls.py @@ -0,0 +1,584 @@ +from django.urls import path + + +# Create your urls here. + +from plane.api.views import ( + SignInEndpoint, + SignOutEndpoint, + MagicSignInEndpoint, + MagicSignInGenerateEndpoint, + ForgotPasswordEndpoint, + PeopleEndpoint, + UserEndpoint, + VerifyEmailEndpoint, + ResetPasswordEndpoint, + RequestEmailVerificationEndpoint, + OauthEndpoint, + ChangePasswordEndpoint, +) + +from plane.api.views import ( + UserWorkspaceInvitationsEndpoint, + WorkSpaceViewSet, + UserWorkSpacesEndpoint, + InviteWorkspaceEndpoint, + JoinWorkspaceEndpoint, + WorkSpaceMemberViewSet, + WorkspaceInvitationsViewset, + UserWorkspaceInvitationsEndpoint, + ProjectViewSet, + InviteProjectEndpoint, + ProjectMemberViewSet, + ProjectMemberInvitationsViewset, + StateViewSet, + ShortCutViewSet, + ViewViewSet, + CycleViewSet, + FileAssetEndpoint, + IssueViewSet, + UserIssuesEndpoint, + WorkSpaceIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + TeamMemberViewSet, + TimeLineIssueViewSet, + CycleIssueViewSet, + IssuePropertyViewSet, + UpdateUserOnBoardedEndpoint, + UserWorkspaceInvitationEndpoint, + UserProjectInvitationsViewset, + ProjectIdentifierEndpoint, + LabelViewSet, + AddMemberToProjectEndpoint, + ProjectJoinEndpoint, + BulkDeleteIssuesEndpoint, + BulkAssignIssuesToCycleEndpoint, +) + +from plane.api.views.project import AddTeamToProjectEndpoint + + +urlpatterns = [ + # Social Auth + path("social-auth/", OauthEndpoint.as_view(), name="oauth"), + # Auth + path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), + path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), + # Magic Sign In/Up + path( + "magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate" + ), + path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), + # Email verification + path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), + path( + "request-email-verify/", + RequestEmailVerificationEndpoint.as_view(), + name="request-reset-email", + ), + # Password Manipulation + path( + "password-reset///", + ResetPasswordEndpoint.as_view(), + name="password-reset", + ), + path( + "forgot-password/", + ForgotPasswordEndpoint.as_view(), + name="forgot-password", + ), + # List Users + path("users/", PeopleEndpoint.as_view()), + # User Profile + path( + "users/me/", + UserEndpoint.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="users", + ), + path( + "users/me/change-password/", + ChangePasswordEndpoint.as_view(), + name="change-password", + ), + path( + "users/me/onboard/", + UpdateUserOnBoardedEndpoint.as_view(), + name="change-password", + ), + # user workspaces + path( + "users/me/workspaces/", + UserWorkSpacesEndpoint.as_view(), + name="user-workspace", + ), + # user workspace invitations + path( + "users/me/invitations/workspaces/", + UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}), + name="user-workspace-invitations", + ), + # user workspace invitation + path( + "users/me/invitations//", + UserWorkspaceInvitationEndpoint.as_view( + { + "get": "retrieve", + } + ), + name="workspace", + ), + # user join workspace + path( + "users/me/invitations/workspaces///join/", + JoinWorkspaceEndpoint.as_view(), + name="user-join-workspace", + ), + # user project invitations + path( + "users/me/invitations/projects/", + UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="user-project-invitaions", + ), + # user issues + path( + "users/me/issues/", + UserIssuesEndpoint.as_view(), + name="user-issues", + ), + ## Workspaces ## + path( + "workspaces/", + WorkSpaceViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace", + ), + path( + "workspaces//", + WorkSpaceViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace", + ), + path( + "workspaces//invite/", + InviteWorkspaceEndpoint.as_view(), + name="workspace", + ), + path( + "workspaces//invitations/", + WorkspaceInvitationsViewset.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//invitations//", + WorkspaceInvitationsViewset.as_view( + { + "delete": "destroy", + "get": "retrieve", + "get": "retrieve", + } + ), + name="workspace", + ), + path( + "workspaces//members/", + WorkSpaceMemberViewSet.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//members//", + WorkSpaceMemberViewSet.as_view( + { + "put": "update", + "patch": "partial_update", + "delete": "destroy", + "get": "retrieve", + } + ), + name="workspace", + ), + path( + "workspaces//teams/", + TeamMemberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace", + ), + path( + "workspaces//teams//", + TeamMemberViewSet.as_view( + { + "put": "update", + "patch": "partial_update", + "delete": "destroy", + "get": "retrieve", + } + ), + name="workspace", + ), + ## End Workspaces ## + # Projects + path( + "workspaces//projects/", + ProjectViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project", + ), + path( + "workspaces//projects//", + ProjectViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//project-identifiers/", + ProjectIdentifierEndpoint.as_view(), + name="project-identifiers", + ), + path( + "workspaces//projects//invite/", + InviteProjectEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects//members/", + ProjectMemberViewSet.as_view({"get": "list"}), + name="project", + ), + path( + "workspaces//projects//members//", + ProjectMemberViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//projects//members/add/", + AddMemberToProjectEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects/join/", + ProjectJoinEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects//team-invite/", + AddTeamToProjectEndpoint.as_view(), + name="projects", + ), + path( + "workspaces//projects//invitations/", + ProjectMemberInvitationsViewset.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//projects//invitations//", + ProjectMemberInvitationsViewset.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project", + ), + # States + path( + "workspaces//projects//states/", + StateViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-states", + ), + path( + "workspaces//projects//states//", + StateViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-state", + ), + # End States ## + # Shortcuts + path( + "workspaces//projects//shortcuts/", + ShortCutViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-shortcut", + ), + path( + "workspaces//projects//shortcuts//", + ShortCutViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-shortcut", + ), + ## End Shortcuts + # Views + path( + "workspaces//projects//views/", + ViewViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-view", + ), + path( + "workspaces//projects//views//", + ViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-view", + ), + ## End Views + ## Cycles + path( + "workspaces//projects//cycles/", + CycleViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//", + CycleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues/", + CycleIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//bulk-assign-issues/", + BulkAssignIssuesToCycleEndpoint.as_view(), + name="bulk-assign-cycle-issues", + ), + ## End Cycles + # Issue + path( + "workspaces//projects//issues/", + IssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue", + ), + path( + "workspaces//projects//issues//", + IssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue", + ), + path( + "workspaces//issues/", + WorkSpaceIssuesEndpoint.as_view(), + name="workspace-issue", + ), + path( + "workspaces//projects//issue-labels/", + LabelViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-labels", + ), + path( + "workspaces//projects//issue-labels//", + LabelViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-labels", + ), + path( + "workspaces//projects//bulk-delete-issues/", + BulkDeleteIssuesEndpoint.as_view(), + ), + ## End Issues + ## Issue Activity + path( + "workspaces//projects//issues//history/", + IssueActivityEndpoint.as_view(), + name="project-issue-history", + ), + ## Issue Activity + ## IssueComments + path( + "workspaces//projects//issues//comments/", + IssueCommentViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-comment", + ), + ## End IssueComments + ## Roadmap + path( + "workspaces//projects//issues//roadmaps/", + TimeLineIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-roadmap", + ), + path( + "workspaces//projects//issues//roadmaps//", + TimeLineIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-roadmap", + ), + ## End Roadmap + ## IssueProperty + path( + "workspaces//projects//issue-properties/", + IssuePropertyViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-roadmap", + ), + path( + "workspaces//projects//issue-properties//", + IssuePropertyViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-roadmap", + ), + ## IssueProperty Ebd + ## File Assets + path( + "file-assets/", + FileAssetEndpoint.as_view(), + name="File Assets", + ), + ## End File Assets + # path( + # "issues//all/", + # IssueViewSet.as_view({"get": "list_issue_history_comments"}), + # name="Issue history and comments", + # ), +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py new file mode 100644 index 000000000..171b1b408 --- /dev/null +++ b/apiserver/plane/api/views/__init__.py @@ -0,0 +1,66 @@ +from .project import ( + ProjectViewSet, + ProjectMemberViewSet, + UserProjectInvitationsViewset, + InviteProjectEndpoint, + AddTeamToProjectEndpoint, + ProjectMemberInvitationsViewset, + ProjectMemberInviteDetailViewSet, + ProjectIdentifierEndpoint, + AddMemberToProjectEndpoint, + ProjectJoinEndpoint, +) +from .people import ( + PeopleEndpoint, + UserEndpoint, + UpdateUserOnBoardedEndpoint, +) + +from .oauth import OauthEndpoint + +from .base import BaseAPIView, BaseViewSet + +from .workspace import ( + WorkSpaceViewSet, + UserWorkSpacesEndpoint, + WorkSpaceAvailabilityCheckEndpoint, + InviteWorkspaceEndpoint, + JoinWorkspaceEndpoint, + WorkSpaceMemberViewSet, + TeamMemberViewSet, + WorkspaceInvitationsViewset, + UserWorkspaceInvitationsEndpoint, + UserWorkspaceInvitationEndpoint, +) +from .state import StateViewSet +from .shortcut import ShortCutViewSet +from .view import ViewViewSet +from .cycle import CycleViewSet, CycleIssueViewSet, BulkAssignIssuesToCycleEndpoint +from .asset import FileAssetEndpoint +from .issue import ( + IssueViewSet, + UserIssuesEndpoint, + WorkSpaceIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + TimeLineIssueViewSet, + IssuePropertyViewSet, + LabelViewSet, + BulkDeleteIssuesEndpoint, +) + +from .auth_extended import ( + VerifyEmailEndpoint, + RequestEmailVerificationEndpoint, + ForgotPasswordEndpoint, + ResetPasswordEndpoint, + ChangePasswordEndpoint, +) + + +from .authentication import ( + SignInEndpoint, + SignOutEndpoint, + MagicSignInEndpoint, + MagicSignInGenerateEndpoint, +) diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py new file mode 100644 index 000000000..8d462b0cb --- /dev/null +++ b/apiserver/plane/api/views/asset.py @@ -0,0 +1,30 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser + +# Module imports +from .base import BaseAPIView +from plane.db.models import FileAsset +from plane.api.serializers import FileAssetSerializer + + +class FileAssetEndpoint(BaseAPIView): + + parser_classes = (MultiPartParser, FormParser) + + """ + A viewset for viewing and editing task instances. + """ + + def get(self, request): + files = FileAsset.objects.all() + serializer = FileAssetSerializer(files, context={"request": request}, many=True) + return Response(serializer.data) + + def post(self, request, *args, **kwargs): + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py new file mode 100644 index 000000000..0e24e39d0 --- /dev/null +++ b/apiserver/plane/api/views/auth_extended.py @@ -0,0 +1,159 @@ +## Python imports +import jwt + +## Django imports +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.encoding import ( + smart_str, + smart_bytes, + DjangoUnicodeDecodeError, +) +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.contrib.sites.shortcuts import get_current_site +from django.conf import settings + +## Third Party Imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework import permissions +from rest_framework_simplejwt.tokens import RefreshToken + +from sentry_sdk import capture_exception + +## Module imports +from . import BaseAPIView +from plane.api.serializers.people import ( + ChangePasswordSerializer, + ResetPasswordSerializer, +) +from plane.db.models import User +from plane.bgtasks.email_verification_task import email_verification +from plane.bgtasks.forgot_password_task import forgot_password + + +class RequestEmailVerificationEndpoint(BaseAPIView): + def get(self, request): + token = RefreshToken.for_user(request.user).access_token + current_site = settings.WEB_URL + email_verification.delay( + request.user.first_name, request.user.email, token, current_site + ) + return Response( + {"message": "Email sent successfully"}, status=status.HTTP_200_OK + ) + + +class VerifyEmailEndpoint(BaseAPIView): + def get(self, request): + token = request.GET.get("token") + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256") + user = User.objects.get(id=payload["user_id"]) + + if not user.is_email_verified: + user.is_email_verified = True + user.save() + return Response( + {"email": "Successfully activated"}, status=status.HTTP_200_OK + ) + except jwt.ExpiredSignatureError as indentifier: + return Response( + {"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST + ) + except jwt.exceptions.DecodeError as indentifier: + return Response( + {"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST + ) + + +class ForgotPasswordEndpoint(BaseAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + email = request.data.get("email") + + if User.objects.filter(email=email).exists(): + user = User.objects.get(email=email) + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + current_site = settings.WEB_URL + + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + + return Response( + {"messgae": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Please check the email"}, status=status.HTTP_400_BAD_REQUEST + ) + + +class ResetPasswordEndpoint(BaseAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request, uidb64, token): + try: + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + if not PasswordResetTokenGenerator().check_token(user, token): + return Response( + {"error": "token is not valid, please check the new one"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + serializer = ResetPasswordSerializer(data=request.data) + + if serializer.is_valid(): + # set_password also hashes the password that the user will get + user.set_password(serializer.data.get("new_password")) + user.save() + response = { + "status": "success", + "code": status.HTTP_200_OK, + "message": "Password updated successfully", + } + + return Response(response) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except DjangoUnicodeDecodeError as indentifier: + return Response( + {"error": "token is not valid, please check the new one"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +class ChangePasswordEndpoint(BaseAPIView): + def post(self, request): + try: + serializer = ChangePasswordSerializer(data=request.data) + + user = User.objects.get(pk=request.user.id) + if serializer.is_valid(): + # Check old password + if not user.object.check_password(serializer.data.get("old_password")): + return Response( + {"old_password": ["Wrong password."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + # set_password also hashes the password that the user will get + self.object.set_password(serializer.data.get("new_password")) + self.object.save() + response = { + "status": "success", + "code": status.HTTP_200_OK, + "message": "Password updated successfully", + } + + return Response(response) + + return Response(serializer.errors, 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_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py new file mode 100644 index 000000000..b1d321f9c --- /dev/null +++ b/apiserver/plane/api/views/authentication.py @@ -0,0 +1,299 @@ +# Python imports +import uuid +import random +import string +import json + +# Django imports +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.conf import settings + +# Third party imports +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework_simplejwt.tokens import RefreshToken + +from sentry_sdk import capture_exception, capture_message + +# Module imports +from . import BaseAPIView +from plane.db.models import User +from plane.api.serializers import UserSerializer +from plane.settings.redis import redis_instance +from plane.bgtasks.magic_link_code_task import magic_link + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +class SignInEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + def post(self, request): + try: + email = request.data.get("email", False) + password = request.data.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + return Response( + {"error": "Both email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError as e: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.get(email=email) + + if not user.check_password(password): + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + serialized_user = UserSerializer(user).data + + # settings last active for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + except User.DoesNotExist: + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + except Exception as e: + print(e) + capture_exception(e) + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class SignOutEndpoint(BaseAPIView): + def post(self, request): + try: + refresh_token = request.data.get("refresh_token", False) + + if not refresh_token: + capture_message("No refresh token provided") + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.get(pk=request.user.id) + + user.last_logout_time = timezone.now() + user.last_logout_ip = request.META.get("REMOTE_ADDR") + + user.save() + + token = RefreshToken(refresh_token) + token.blacklist() + return Response({"message": "success"}, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class MagicSignInGenerateEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + try: + + email = request.data.get("email", False) + + if not email: + return Response( + {"error": "Please provide a valid email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validate_email(email) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + ) + + ri = redis_instance() + + key = "magic_" + str(email) + + # Check if the key already exists in python + if ri.exists(key): + data = json.loads(ri.get(key)) + + current_attempt = data["current_attempt"] + 1 + + if data["current_attempt"] > 2: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + value = { + "current_attempt": current_attempt, + "email": email, + "token": token, + } + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + else: + + value = {"current_attempt": 0, "email": email, "token": token} + expiry = 600 + + ri.set(key, json.dumps(value), ex=expiry) + + current_site = settings.WEB_URL + magic_link.delay(email, key, token, current_site) + + return Response({"key": key}, status=status.HTTP_200_OK) + except ValidationError: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + print(e) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class MagicSignInEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + try: + + user_token = request.data.get("token", "").strip().lower() + key = request.data.get("key", False) + + if not key or user_token == "": + return Response( + {"error": "User token and key are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ri = redis_instance() + + if ri.exists(key): + + data = json.loads(ri.get(key)) + + token = data["token"] + email = data["email"] + + if str(token) == str(user_token): + + if User.objects.filter(email=email).exists(): + user = User.objects.get(email=email) + else: + user = User.objects.create( + email=email, username=uuid.uuid4().hex + ) + + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + else: + return Response( + {"error": "Your login code was incorrect. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + else: + return Response( + {"error": "The magic code/link has expired please try again"}, + 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_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py new file mode 100644 index 000000000..f74542aac --- /dev/null +++ b/apiserver/plane/api/views/base.py @@ -0,0 +1,142 @@ +# Django imports +from django.urls import resolve +from django.conf import settings +# Third part imports +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import APIException +from rest_framework.views import APIView +from rest_framework.filters import SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import NotFound + +from django_filters.rest_framework import DjangoFilterBackend + +# Module imports +from plane.db.models import Workspace, Project +from plane.utils.paginator import BasePaginator + + +class BaseViewSet(ModelViewSet, BasePaginator): + + model = None + + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + print(e) + raise APIException( + "Please check the view", status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + print(f'# of Queries: {len(connection.queries)}') + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def workspace(self): + if self.workspace_slug: + try: + return Workspace.objects.get(slug=self.workspace_slug) + except Workspace.DoesNotExist: + raise NotFound(detail="Workspace does not exist") + else: + return None + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def project(self): + if self.project_id: + try: + return Project.objects.get(pk=self.project_id) + except Project.DoesNotExist: + raise NotFound(detail="Project does not exist") + else: + return None + + +class BaseAPIView(APIView, BasePaginator): + + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + print(f'# of Queries: {len(connection.queries)}') + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def workspace(self): + if self.workspace_slug: + try: + return Workspace.objects.get(slug=self.workspace_slug) + except Workspace.DoesNotExist: + raise NotFound(detail="Workspace does not exist") + else: + return None + + @property + def project_id(self): + return self.kwargs.get("project_id", None) + + @property + def project(self): + if self.project_id: + try: + return Project.objects.get(pk=self.project_id) + except Project.DoesNotExist: + raise NotFound(detail="Project does not exist") + else: + return None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py new file mode 100644 index 000000000..8b74f2a10 --- /dev/null +++ b/apiserver/plane/api/views/cycle.py @@ -0,0 +1,109 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet, BaseAPIView +from plane.api.serializers import CycleSerializer, CycleIssueSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Cycle, CycleIssue, Issue + + +class CycleViewSet(BaseViewSet): + + serializer_class = CycleSerializer + model = Cycle + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), owned_by=self.request.user + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + +class CycleIssueViewSet(BaseViewSet): + + serializer_class = CycleIssueSerializer + model = CycleIssue + + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + cycle_id=self.kwargs.get("cycle_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue") + .select_related("issue__state") + .distinct() + ) + + +class BulkAssignIssuesToCycleEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, cycle_id): + try: + + issue_ids = request.data.get("issue_ids") + + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + + issues = Issue.objects.filter( + pk__in=issue_ids, workspace__slug=slug, project_id=project_id + ) + + CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace=cycle.workspace, + created_by=request.user, + updated_by=request.user, + cycle=cycle, + issue=issue, + ) + for issue in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + return Response({"message": "Success"}, status=status.HTTP_200_OK) + + except Cycle.DoesNotExist: + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py new file mode 100644 index 000000000..c6c30e867 --- /dev/null +++ b/apiserver/plane/api/views/issue.py @@ -0,0 +1,394 @@ +# Python imports +from itertools import groupby + +# Django imports +from django.db.models import Prefetch +from django.db.models import Count, Sum + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + +# Module imports +from . import BaseViewSet, BaseAPIView +from plane.api.serializers import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + TimeLineIssueSerializer, + IssuePropertySerializer, + LabelSerializer, + IssueSerializer, + LabelSerializer, +) +from plane.api.permissions import ( + ProjectEntityPermission, + WorkSpaceAdminPermission, + ProjectMemberPermission, +) +from plane.db.models import ( + Project, + Issue, + IssueActivity, + IssueComment, + TimelineIssue, + IssueProperty, + Label, + IssueBlocker, +) + + +class IssueViewSet(BaseViewSet): + def get_serializer_class(self): + return ( + IssueCreateSerializer + if self.action in ["create", "update", "partial_update"] + else IssueSerializer + ) + + model = Issue + permission_classes = [ + ProjectEntityPermission, + ] + + search_fields = [ + "name", + ] + + filterset_fields = [ + "state__name", + "assignees__id", + "workspace__id", + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return ( + super() + .get_queryset() + .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") + .prefetch_related( + Prefetch( + "blocked_issues", + queryset=IssueBlocker.objects.select_related("blocked_by", "block"), + ) + ) + .prefetch_related( + Prefetch( + "blocker_issues", + queryset=IssueBlocker.objects.select_related("block", "blocked_by"), + ) + ) + ) + + def grouper(self, issue, group_by): + group_by = issue.get(group_by, "") + + if isinstance(group_by, list): + if len(group_by): + return group_by[0] + else: + return "" + + else: + return group_by + + def list(self, request, slug, project_id): + try: + issue_queryset = self.get_queryset() + + ## Grouping the results + group_by = request.GET.get("group_by", False) + # TODO: Move this group by from ittertools to ORM for better performance - nk + if group_by: + issue_dict = dict() + + issues = IssueSerializer(issue_queryset, many=True).data + + for key, value in groupby( + issues, lambda issue: self.grouper(issue, group_by) + ): + issue_dict[str(key)] = list(value) + + return Response(issue_dict, status=status.HTTP_200_OK) + + return self.paginate( + request=request, + queryset=issue_queryset, + on_results=lambda issues: IssueSerializer(issues, many=True).data, + ) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def create(self, request, slug, project_id): + try: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + serializer = IssueCreateSerializer( + data=request.data, context={"project": project} + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + + +class UserIssuesEndpoint(BaseAPIView): + def get(self, request): + try: + issues = Issue.objects.filter(assignees__in=[request.user]) + serializer = IssueSerializer(issues, 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_500_INTERNAL_SERVER_ERROR, + ) + + +class WorkSpaceIssuesEndpoint(BaseAPIView): + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def get(self, request, slug): + try: + issues = Issue.objects.filter(workspace__slug=slug).filter( + project__project_projectmember__member=self.request.user + ) + serializer = IssueSerializer(issues, 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_500_INTERNAL_SERVER_ERROR, + ) + + +class IssueActivityEndpoint(BaseAPIView): + def get(self, request, slug, project_id, issue_id): + try: + issue_activities = IssueActivity.objects.filter(issue_id=issue_id).filter( + project__project_projectmember__member=self.request.user + ) + serializer = IssueActivitySerializer(issue_activities, 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_500_INTERNAL_SERVER_ERROR, + ) + + +class IssueCommentViewSet(BaseViewSet): + + serializer_class = IssueCommentSerializer + model = IssueComment + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + actor=self.request.user if self.request.user is not None else None, + ) + + def get_queryset(self): + return self.filter_queryset( + 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) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + +class TimeLineIssueViewSet(BaseViewSet): + serializer_class = TimeLineIssueSerializer + model = TimelineIssue + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + 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 self.filter_queryset( + 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) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + +class IssuePropertyViewSet(BaseViewSet): + serializer_class = IssuePropertySerializer + model = IssueProperty + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), user=self.request.user + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(user=self.request.user) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + ) + + def list(self, request, slug, project_id): + queryset = self.get_queryset() + serializer = IssuePropertySerializer(queryset, many=True) + return Response( + serializer.data[0] if len(serializer.data) > 0 else [], + status=status.HTTP_200_OK, + ) + + def create(self, request, slug, project_id): + try: + + issue_property, created = IssueProperty.objects.get_or_create( + user=request.user, + project_id=project_id, + ) + + if not created: + issue_property.properties = request.data.get("properties", {}) + issue_property.save() + + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + issue_property.properties = request.data.get("properties", {}) + issue_property.save() + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.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_500_INTERNAL_SERVER_ERROR, + ) + + +class LabelViewSet(BaseViewSet): + + serializer_class = LabelSerializer + model = Label + permission_classes = [ + ProjectMemberPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("parent") + .distinct() + ) + + +class BulkDeleteIssuesEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def delete(self, request, slug, project_id): + try: + + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + + total_issues = len(issues) + + issues.delete() + + return Response( + {"message": f"{total_issues} issues were deleted"}, 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_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py new file mode 100644 index 000000000..ac8b55c7e --- /dev/null +++ b/apiserver/plane/api/views/oauth.py @@ -0,0 +1,269 @@ +# Python imports +import uuid +import requests +import os + +# Django imports +from django.utils import timezone + +# Third Party modules +from rest_framework.response import Response +from rest_framework import exceptions +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework import status + +# sso authentication +from google.oauth2 import id_token +from google.auth.transport import requests as google_auth_request + +# Module imports +from plane.db.models import SocialLoginConnection, User +from plane.api.serializers import UserSerializer +from .base import BaseAPIView + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +def validate_google_token(token, client_id): + try: + + id_info = id_token.verify_oauth2_token( + token, google_auth_request.Request(), client_id + ) + email = id_info.get("email") + first_name = id_info.get("given_name") + last_name = id_info.get("family_name", "") + data = { + "email": email, + "first_name": first_name, + "last_name": last_name, + } + return data + except Exception as e: + print(e) + raise exceptions.AuthenticationFailed("Error with Google connection.") + + +def get_access_token(request_token: str, client_id: str) -> str: + """Obtain the request token from github. + Given the client id, client secret and request issued out by GitHub, this method + should give back an access token + Parameters + ---------- + CLIENT_ID: str + A string representing the client id issued out by github + CLIENT_SECRET: str + A string representing the client secret issued out by github + request_token: str + A string representing the request token issued out by github + Throws + ------ + ValueError: + if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string + Returns + ------- + access_token: str + A string representing the access token issued out by github + """ + + if not request_token: + raise ValueError("The request token has to be supplied!") + + CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET") + + url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}" + headers = {"accept": "application/json"} + + res = requests.post(url, headers=headers) + + data = res.json() + access_token = data["access_token"] + + return access_token + + +def get_user_data(access_token: str) -> dict: + """ + Obtain the user data from github. + Given the access token, this method should give back the user data + """ + if not access_token: + raise ValueError("The request token has to be supplied!") + if not isinstance(access_token, str): + raise ValueError("The request token has to be a string!") + + access_token = "token " + access_token + url = "https://api.github.com/user" + headers = {"Authorization": access_token} + + resp = requests.get(url=url, headers=headers) + + userData = resp.json() + + return userData + + +class OauthEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def post(self, request): + try: + + medium = request.data.get("medium", False) + id_token = request.data.get("credential", False) + client_id = request.data.get("clientId", False) + + if not medium or not id_token: + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if medium == "google": + data = validate_google_token(id_token, client_id) + + if medium == "github": + access_token = get_access_token(id_token, client_id) + data = get_user_data(access_token) + + email = data.get("email", None) + if email == None: + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if "@" in email: + user = User.objects.get(email=email) + email = data["email"] + channel = "email" + mobile_number = uuid.uuid4().hex + email_verified = True + else: + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ## Login Case + + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_medium = f"oauth" + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.is_email_verified = email_verified + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + + return Response(data, status=status.HTTP_200_OK) + + except User.DoesNotExist: + ## Signup Case + + username = uuid.uuid4().hex + + if "@" in email: + email = data["email"] + mobile_number = uuid.uuid4().hex + channel = "email" + email_verified = True + else: + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User( + username=username, + email=email, + mobile_number=mobile_number, + first_name=data["first_name"], + last_name=data["last_name"], + is_email_verified=email_verified, + is_password_autoset=True, + ) + + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_medium = "oauth" + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + "permissions": [], + } + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + return Response(data, status=status.HTTP_201_CREATED) + except Exception as e: + print(e) + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py new file mode 100644 index 000000000..1612e0bc7 --- /dev/null +++ b/apiserver/plane/api/views/people.py @@ -0,0 +1,76 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +from sentry_sdk import capture_exception + +# Module imports +from plane.api.serializers import ( + UserSerializer, +) + +from plane.api.views.base import BaseViewSet, BaseAPIView +from plane.db.models import User + + + +class PeopleEndpoint(BaseAPIView): + + filterset_fields = ("date_joined",) + + search_fields = ( + "^first_name", + "^last_name", + "^email", + "^username", + ) + + def get(self, request): + try: + users = User.objects.all().order_by("-date_joined") + if ( + request.GET.get("search", None) is not None + and len(request.GET.get("search")) < 3 + ): + return Response( + {"message": "Search term must be at least 3 characters long"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return self.paginate( + request=request, + queryset=self.filter_queryset(users), + on_results=lambda data: UserSerializer(data, many=True).data, + ) + except Exception as e: + capture_exception(e) + return Response( + {"message": "Something went wrong"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UserEndpoint(BaseViewSet): + serializer_class = UserSerializer + model = User + serializers = {} + + def get_object(self): + return self.request.user + + + +class UpdateUserOnBoardedEndpoint(BaseAPIView): + def patch(self, request): + try: + user = User.objects.get(pk=request.user.id) + user.is_onboarded = request.data.get("is_onboarded", 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, + ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py new file mode 100644 index 000000000..081371984 --- /dev/null +++ b/apiserver/plane/api/views/project.py @@ -0,0 +1,526 @@ +# Python imports +import jwt +from datetime import datetime + +# Django imports +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db.models import Q +from django.core.validators import validate_email +from django.conf import settings + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework import serializers +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.api.serializers import ( + ProjectSerializer, + ProjectMemberSerializer, + ProjectDetailSerializer, + ProjectMemberInviteSerializer, + ProjectIdentifierSerializer, +) + +from plane.api.permissions import ProjectBasePermission + +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + ProjectMemberInvite, + User, + WorkspaceMember, + State, + TeamMember, +) + +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + ProjectMemberInvite, + User, + ProjectIdentifier, +) +from plane.bgtasks.project_invitation_task import project_invitation + + +class ProjectViewSet(BaseViewSet): + serializer_class = ProjectSerializer + model = Project + + permission_classes = [ + ProjectBasePermission, + ] + + def get_serializer_class(self, *args, **kwargs): + if self.action == "update" or self.action == "partial_update": + return ProjectSerializer + return ProjectDetailSerializer + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) + .select_related("workspace", "workspace__owner") + .distinct() + ) + + def create(self, request, slug): + try: + + workspace = Workspace.objects.get(slug=slug) + + serializer = ProjectSerializer( + data={**request.data}, context={"workspace_id": workspace.id} + ) + if serializer.is_valid(): + serializer.save() + + ## Add the user as Administrator to the project + ProjectMember.objects.create( + project_id=serializer.data["id"], member=request.user, role=20 + ) + + ## Default states + states = [ + {"name": "Backlog", "color": "#5e6ad2", "sequence": 15000}, + {"name": "ToDo", "color": "#eb5757", "sequence": 25000}, + {"name": "Started", "color": "#26b5ce", "sequence": 35000}, + {"name": "InProgress", "color": "#f2c94c", "sequence": 45000}, + {"name": "Done", "color": "#4cb782", "sequence": 55000}, + {"name": "Cancelled", "color": "#cc1d10", "sequence": 65000}, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + ) + for state in states + ] + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_410_GONE, + ) + except serializers.ValidationError as e: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_410_GONE, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class InviteProjectEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + try: + + email = request.data.get("email", False) + role = request.data.get("role", False) + + # Check if email is provided + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + validate_email(email) + # Check if user is already a member of workspace + if ProjectMember.objects.filter( + project_id=project_id, member__email=email + ).exists(): + return Response( + {"error": "User is already member of workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email=email).first() + + if user is None: + token = jwt.encode( + {"email": email, "timestamp": datetime.now().timestamp()}, + settings.SECRET_KEY, + algorithm="HS256", + ) + project_invitation_obj = ProjectMemberInvite.objects.create( + email=email.strip().lower(), + project_id=project_id, + token=token, + role=role, + ) + domain = settings.WEB_URL + project_invitation.delay(email, project_id, token, domain) + + return Response( + { + "message": "Email sent successfully", + "id": project_invitation_obj.id, + }, + status=status.HTTP_200_OK, + ) + + project_member = ProjectMember.objects.create( + member=user, project_id=project_id, role=role + ) + + return Response( + ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK + ) + + except ValidationError: + return Response( + { + "error": "Invalid email address provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except (Workspace.DoesNotExist, Project.DoesNotExist) as e: + return Response( + {"error": "Workspace or Project 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_500_INTERNAL_SERVER_ERROR, + ) + + +class UserProjectInvitationsViewset(BaseViewSet): + + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(email=self.request.user.email) + .select_related("workspace") + ) + + def create(self, request): + try: + + invitations = request.data.get("invitations") + project_invitations = ProjectMemberInvite.objects.filter( + pk__in=invitations, accepted=True + ) + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=invitation.project, + workspace=invitation.project.workspace, + member=request.user, + role=invitation.role, + ) + for invitation in project_invitations + ] + ) + + ## Delete joined project invites + project_invitations.delete() + + return Response(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_500_INTERNAL_SERVER_ERROR, + ) + + +class ProjectMemberViewSet(BaseViewSet): + + serializer_class = ProjectMemberSerializer + model = ProjectMember + permission_classes = [ + ProjectBasePermission, + ] + + search_fields = [ + "member__email", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .select_related("project") + .select_related("member") + ) + + +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) + + 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, + ) + + # Check if the user is already member of project + if ProjectMember.objects.filter( + project=project_id, member_id=member_id + ).exists(): + return Response( + {"error": "User is already a member of the project"}, + 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 + ) + + serializer = ProjectMemberSerializer(project_member) + + return Response(serializer.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_500_INTERNAL_SERVER_ERROR, + ) + + +class AddTeamToProjectEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + + try: + team_members = TeamMember.objects.filter( + workspace__slug=slug, team__in=request.data.get("teams", []) + ).values_list("member", flat=True) + + if len(team_members) == 0: + return Response( + {"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST + ) + + workspace = Workspace.objects.get(slug=slug) + + project_members = [] + for member in team_members: + project_members.append( + ProjectMember( + project_id=project_id, + member_id=member, + workspace=workspace, + ) + ) + + ProjectMember.objects.bulk_create( + project_members, batch_size=10, ignore_conflicts=True + ) + + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "The team with the name already exists"}, + status=status.HTTP_410_GONE, + ) + except Workspace.DoesNotExist: + return Response( + {"error": "The requested workspace could not be found"}, + 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_500_INTERNAL_SERVER_ERROR, + ) + + +class ProjectMemberInvitationsViewset(BaseViewSet): + + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + search_fields = [] + + permission_classes = [ + ProjectBasePermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .select_related("project") + ) + + +class ProjectMemberInviteDetailViewSet(BaseViewSet): + + serializer_class = ProjectMemberInviteSerializer + model = ProjectMemberInvite + + search_fields = [] + + permission_classes = [ + ProjectBasePermission, + ] + + def get_queryset(self): + return self.filter_queryset(super().get_queryset().select_related("project")) + + +class ProjectIdentifierEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def get(self, request, slug): + try: + + name = request.GET.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + exists = ProjectIdentifier.objects.filter(name=name).values( + "id", "name", "project" + ) + + return Response( + {"exists": len(exists), "identifiers": exists}, + 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_500_INTERNAL_SERVER_ERROR, + ) + + def delete(self, request, slug): + try: + + name = request.data.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + if Project.objects.filter(identifier=name).exists(): + return Response( + {"error": "Cannot delete an identifier of an existing project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter(name=name).delete() + + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class ProjectJoinEndpoint(BaseAPIView): + def post(self, request, slug): + try: + project_ids = request.data.get("project_ids", []) + + # Get the workspace user role + workspace_member = WorkspaceMember.objects.get( + member=request.user, workspace__slug=slug + ) + + workspace_role = workspace_member.role + workspace = workspace_member.workspace + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=project_id, + member=request.user, + role=20 + if workspace_role >= 15 + else (15 if workspace_role == 10 else workspace_role), + workspace=workspace, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + return Response( + {"message": "Projects joined successfully"}, + status=status.HTTP_201_CREATED, + ) + except WorkspaceMember.DoesNotExist: + return Response( + {"error": "User is not a member of workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/shortcut.py b/apiserver/plane/api/views/shortcut.py new file mode 100644 index 000000000..49453fb14 --- /dev/null +++ b/apiserver/plane/api/views/shortcut.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import ShortCutSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Shortcut + + +class ShortCutViewSet(BaseViewSet): + + serializer_class = ShortCutSerializer + model = Shortcut + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py new file mode 100644 index 000000000..8054b15dd --- /dev/null +++ b/apiserver/plane/api/views/state.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import StateSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import State + + +class StateViewSet(BaseViewSet): + + serializer_class = StateSerializer + model = State + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py new file mode 100644 index 000000000..4ae4ff2c1 --- /dev/null +++ b/apiserver/plane/api/views/view.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import ViewSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import View + + +class ViewViewSet(BaseViewSet): + + serializer_class = ViewSerializer + model = View + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py new file mode 100644 index 000000000..8e10a72b9 --- /dev/null +++ b/apiserver/plane/api/views/workspace.py @@ -0,0 +1,462 @@ +# Python imports +import jwt +from datetime import datetime + +# Django imports +from django.db import IntegrityError +from django.db.models import Prefetch +from django.conf import settings +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.contrib.sites.shortcuts import get_current_site +from django.db.models import CharField +from django.db.models.functions import Cast + +# Third party modules +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from sentry_sdk import capture_exception + +# Module imports +from plane.api.serializers import ( + WorkSpaceSerializer, + WorkSpaceMemberSerializer, + TeamSerializer, + WorkSpaceMemberInviteSerializer, + UserLiteSerializer, +) +from plane.api.views.base import BaseAPIView +from . import BaseViewSet +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + WorkspaceMemberInvite, + Team, +) +from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission +from plane.bgtasks.workspace_invitation_task import workspace_invitation + + +class WorkSpaceViewSet(BaseViewSet): + + model = Workspace + serializer_class = WorkSpaceSerializer + permission_classes = [ + WorkSpaceBasePermission, + ] + + search_fields = [ + "name", + ] + filterset_fields = [ + "owner", + ] + + lookup_field = "slug" + + def get_queryset(self): + return self.filter_queryset(super().get_queryset().select_related("owner")) + + def create(self, request): + try: + serializer = WorkSpaceSerializer(data=request.data) + + if serializer.is_valid(): + serializer.save(owner=request.user) + # Create Workspace member + _ = WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=request.data.get("company_role", ""), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + + ## Handling unique integrity error for now + ## TODO: Extend this to handle other common errors which are not automatically handled by APIException + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The workspace with the name already exists"}, + status=status.HTTP_410_GONE, + ) + except Exception as e: + capture_exception(e) + return Response( + { + "error": "Something went wrong please try again later", + "identifier": None, + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UserWorkSpacesEndpoint(BaseAPIView): + + search_fields = [ + "name", + ] + filterset_fields = [ + "owner", + ] + + def get(self, request): + try: + workspace = ( + Workspace.objects.prefetch_related( + Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) + ) + .filter( + workspace_member__member=request.user, + ) + .select_related("owner") + ) + serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + print(e) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + name = request.GET.get("name", False) + + if not name: + return Response( + {"error": "Workspace Name is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.filter(name=name).exists() + + return Response({"status": workspace}, 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_500_INTERNAL_SERVER_ERROR, + ) + + +class InviteWorkspaceEndpoint(BaseAPIView): + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def post(self, request, slug): + try: + + email = request.data.get("email", False) + + # Check if email is provided + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + validate_email(email) + # Check if user is already a member of workspace + workspace = Workspace.objects.get(slug=slug) + + if WorkspaceMember.objects.filter( + workspace_id=workspace.id, member__email=email + ).exists(): + return Response( + {"error": "User is already member of workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + token = jwt.encode( + {"email": email, "timestamp": datetime.now().timestamp()}, + settings.SECRET_KEY, + algorithm="HS256", + ) + + workspace_invitation_obj = WorkspaceMemberInvite.objects.create( + email=email.strip().lower(), + workspace_id=workspace.id, + token=token, + role=request.data.get("role", 10), + ) + + domain = settings.WEB_URL + + workspace_invitation.delay( + email, workspace.id, token, domain, request.user.email + ) + + return Response( + { + "message": "Email sent successfully", + "id": workspace_invitation_obj.id, + }, + status=status.HTTP_200_OK, + ) + except ValidationError: + return Response( + { + "error": "Invalid email address provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Workspace.DoesNotExist: + return Response( + {"error": "Workspace does not exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + print(e) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class JoinWorkspaceEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request, slug, pk): + try: + + workspace_invite = WorkspaceMemberInvite.objects.get( + pk=pk, workspace__slug=slug + ) + + email = request.data.get("email", "") + + if email == "" or workspace_invite.email != email: + return Response( + {"error": "You do not have permission to join the workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if workspace_invite.responded_at is None: + workspace_invite.accepted = request.data.get("accepted", False) + workspace_invite.responded_at = timezone.now() + workspace_invite.save() + + if workspace_invite.accepted: + return Response( + {"message": "Workspace Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"message": "Workspace Invitation was not accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "You have already responded to the invitation request"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + except WorkspaceMemberInvite.DoesNotExist: + return Response( + {"error": "The invitation either got expired or could not be found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + print(e) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class WorkspaceInvitationsViewset(BaseViewSet): + + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace") + ) + + +class UserWorkspaceInvitationsEndpoint(BaseViewSet): + + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(email=self.request.user.email) + .select_related("workspace") + ) + + def create(self, request): + try: + + invitations = request.data.get("invitations") + workspace_invitations = WorkspaceMemberInvite.objects.filter( + pk__in=invitations + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=invitation.workspace, + member=request.user, + role=invitation.role, + ) + for invitation in workspace_invitations + ], + ignore_conflicts=True, + ) + + # Delete joined workspace invites + workspace_invitations.delete() + + return Response(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_500_INTERNAL_SERVER_ERROR, + ) + + +class WorkSpaceMemberViewSet(BaseViewSet): + + serializer_class = WorkSpaceMemberSerializer + model = WorkspaceMember + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + search_fields = [ + "member__email", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner") + .select_related("member") + ) + + +class TeamMemberViewSet(BaseViewSet): + + serializer_class = TeamSerializer + model = Team + + search_fields = [ + "member__email", + "member__first_name", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner") + .prefetch_related("members") + ) + + def create(self, request, slug): + + try: + + members = list( + WorkspaceMember.objects.filter( + workspace__slug=slug, member__id__in=request.data.get("members", []) + ) + .annotate(member_str_id=Cast("member", output_field=CharField())) + .distinct() + .values_list("member_str_id", flat=True) + ) + + if len(members) != len(request.data.get("members", [])): + + users = list(set(request.data.get("members", [])).difference(members)) + users = User.objects.filter(pk__in=users) + + serializer = UserLiteSerializer(users, many=True) + return Response( + { + "error": f"{len(users)} of the member(s) are not a part of the workspace", + "members": serializer.data, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + serializer = TeamSerializer( + data=request.data, context={"workspace": workspace} + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "The team with the name already exists"}, + status=status.HTTP_410_GONE, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UserWorkspaceInvitationEndpoint(BaseViewSet): + + model = WorkspaceMemberInvite + serializer_class = WorkSpaceMemberInviteSerializer + + permission_classes = [ + AllowAny, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(pk=self.kwargs.get("pk")) + .select_related("workspace") + ) diff --git a/apiserver/plane/bgtasks/__init__.py b/apiserver/plane/bgtasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/bgtasks/apps.py b/apiserver/plane/bgtasks/apps.py new file mode 100644 index 000000000..03d29f3e0 --- /dev/null +++ b/apiserver/plane/bgtasks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BgtasksConfig(AppConfig): + name = 'plane.bgtasks' diff --git a/apiserver/plane/bgtasks/celery.py b/apiserver/plane/bgtasks/celery.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py new file mode 100644 index 000000000..cf233c531 --- /dev/null +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -0,0 +1,40 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import User + + +@job("default") +def email_verification(first_name, email, token, current_site): + + try: + realtivelink = "/request-email-verification/" + "?token=" + str(token) + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Verify your Email!" + + context = { + "first_name": first_name, + "verification_url": abs_url, + } + + html_content = render_to_string("emails/auth/email_verification.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py new file mode 100644 index 000000000..7d169e8cf --- /dev/null +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -0,0 +1,40 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import User + + +@job("default") +def forgot_password(first_name, email, uidb64, token, current_site): + + try: + realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/" + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Verify your Email!" + + context = { + "first_name": first_name, + "forgot_password_url": abs_url, + } + + html_content = render_to_string("emails/auth/forgot_password.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py new file mode 100644 index 000000000..5673140cd --- /dev/null +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -0,0 +1,35 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + + +@job("default") +def magic_link(email, key, token, current_site): + + try: + realtivelink = f"/magic-sign-in/?password={token}&key={key}" + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Login!" + + context = {"magic_url": abs_url, "code": token} + + html_content = render_to_string("emails/auth/magic_signin.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py new file mode 100644 index 000000000..9b1649f1f --- /dev/null +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -0,0 +1,54 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import Project, User, ProjectMemberInvite + + +@job("default") +def project_invitation(email, project_id, token, current_site): + + try: + + project = Project.objects.get(pk=project_id) + project_member_invite = ProjectMemberInvite.objects.get( + token=token, email=email + ) + + relativelink = f"/project-member-invitation/{project_member_invite.id}" + abs_url = "http://" + current_site + relativelink + + from_email_string = f"Team Plane " + + subject = f"Welcome {email}!" + + context = { + "email": email, + "first_name": project.created_by.first_name, + "project_name": project.name, + "invitation_url": abs_url, + } + + html_content = render_to_string("emails/invitations/project_invitation.html", context) + + text_content = strip_tags(html_content) + + project_member_invite.message = text_content + project_member_invite.save() + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e: + return + except Exception as e: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py new file mode 100644 index 000000000..b85a24a84 --- /dev/null +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -0,0 +1,57 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import Workspace, User, WorkspaceMemberInvite + + +@job("default") +def workspace_invitation(email, workspace_id, token, current_site, invitor): + + try: + + workspace = Workspace.objects.get(pk=workspace_id) + workspace_member_invite = WorkspaceMemberInvite.objects.get( + token=token, email=email + ) + + realtivelink = ( + f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}" + ) + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Welcome {email}!" + + context = { + "email": email, + "first_name": invitor, + "workspace_name": workspace.name, + "invitation_url": abs_url, + } + + html_content = render_to_string( + "emails/invitations/workspace_invitation.html", context + ) + + text_content = strip_tags(html_content) + + workspace_member_invite.message = text_content + workspace_member_invite.save() + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/db/__init__.py b/apiserver/plane/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/admin.py b/apiserver/plane/db/admin.py new file mode 100644 index 000000000..161ec9de6 --- /dev/null +++ b/apiserver/plane/db/admin.py @@ -0,0 +1,35 @@ +# from django.contrib import admin +# from plane.db.models import User +# from plane.db.models.workspace import Workspace, WorkspaceMember, WorkspaceMemberInvite +# from plane.db.models.project import Project, ProjectMember, ProjectMemberInvite +# from plane.db.models.cycle import Cycle, CycleIssue +# from plane.db.models.issue import ( +# Issue, +# IssueActivity, +# IssueComment, +# IssueProperty, +# TimelineIssue, +# ) +# from plane.db.models.shortcut import Shortcut +# from plane.db.models.state import State +# from plane.db.models.social_connection import SocialLoginConnection +# from plane.db.models.view import View + +# admin.site.register(User) +# admin.site.register(Workspace) +# admin.site.register(WorkspaceMember) +# admin.site.register(WorkspaceMemberInvite) +# admin.site.register(Project) +# admin.site.register(ProjectMember) +# admin.site.register(ProjectMemberInvite) +# admin.site.register(Cycle) +# admin.site.register(CycleIssue) +# admin.site.register(Issue) +# admin.site.register(IssueActivity) +# admin.site.register(IssueComment) +# admin.site.register(IssueProperty) +# admin.site.register(TimelineIssue) +# admin.site.register(Shortcut) +# admin.site.register(State) +# admin.site.register(SocialLoginConnection) +# admin.site.register(View) diff --git a/apiserver/plane/db/apps.py b/apiserver/plane/db/apps.py new file mode 100644 index 000000000..70e4445be --- /dev/null +++ b/apiserver/plane/db/apps.py @@ -0,0 +1,52 @@ +from django.apps import AppConfig +from fieldsignals import post_save_changed + + +class DbConfig(AppConfig): + name = "plane.db" + + def ready(self): + + post_save_changed.connect( + self.model_activity, + sender=self.get_model("Issue"), + ) + + def model_activity(self, sender, instance, changed_fields, **kwargs): + + verb = "created" if instance._state.adding else "changed" + + import inspect + + for frame_record in inspect.stack(): + if frame_record[3] == "get_response": + request = frame_record[0].f_locals["request"] + REQUEST_METHOD = request.method + + if REQUEST_METHOD == "POST": + + self.get_model("IssueActivity").objects.create( + issue=instance, project=instance.project, actor=instance.created_by + ) + + elif REQUEST_METHOD == "PATCH": + + try: + del changed_fields["updated_at"] + del changed_fields["updated_by"] + except KeyError as e: + pass + + for field_name, (old, new) in changed_fields.items(): + field = field_name + old_value = old + new_value = new + self.get_model("IssueActivity").objects.create( + issue=instance, + verb=verb, + field=field, + old_value=old_value, + new_value=new_value, + project=instance.project, + actor=instance.updated_by, + ) diff --git a/apiserver/plane/db/migrations/0001_initial.py b/apiserver/plane/db/migrations/0001_initial.py new file mode 100644 index 000000000..dd158f0a8 --- /dev/null +++ b/apiserver/plane/db/migrations/0001_initial.py @@ -0,0 +1,704 @@ +# Generated by Django 3.2.14 on 2022-10-26 19:37 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('username', models.CharField(max_length=128, unique=True)), + ('mobile_number', models.CharField(blank=True, max_length=255, null=True)), + ('email', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('first_name', models.CharField(blank=True, max_length=255)), + ('last_name', models.CharField(blank=True, max_length=255)), + ('avatar', models.CharField(blank=True, max_length=255)), + ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('last_location', models.CharField(blank=True, max_length=255)), + ('created_location', models.CharField(blank=True, max_length=255)), + ('is_superuser', models.BooleanField(default=False)), + ('is_managed', models.BooleanField(default=False)), + ('is_password_expired', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_email_verified', models.BooleanField(default=False)), + ('is_password_autoset', models.BooleanField(default=False)), + ('is_onboarded', models.BooleanField(default=False)), + ('token', models.CharField(blank=True, max_length=64)), + ('billing_address_country', models.CharField(default='INDIA', max_length=255)), + ('billing_address', models.JSONField(null=True)), + ('has_billing_address', models.BooleanField(default=False)), + ('user_timezone', models.CharField(default='Asia/Kolkata', max_length=255)), + ('last_active', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('last_login_time', models.DateTimeField(null=True)), + ('last_logout_time', models.DateTimeField(null=True)), + ('last_login_ip', models.CharField(blank=True, max_length=255)), + ('last_logout_ip', models.CharField(blank=True, max_length=255)), + ('last_login_medium', models.CharField(default='email', max_length=20)), + ('last_login_uagent', models.TextField(blank=True)), + ('token_updated_at', models.DateTimeField(null=True)), + ('last_workspace_id', models.UUIDField(null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'user', + 'ordering': ('-created_at',), + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Cycle', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), + ('description', models.TextField(blank=True, verbose_name='Cycle Description')), + ('start_date', models.DateField(verbose_name='Start Date')), + ('end_date', models.DateField(verbose_name='End Date')), + ('status', models.CharField(choices=[('started', 'Started'), ('completed', 'Completed')], max_length=255, verbose_name='Cycle Status')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_by_cycle', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Cycle', + 'verbose_name_plural': 'Cycles', + 'db_table': 'cycle', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Issue', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Issue Name')), + ('description', models.JSONField(blank=True, verbose_name='Issue Description')), + ('priority', models.CharField(blank=True, choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], max_length=30, null=True, verbose_name='Issue Priority')), + ('start_date', models.DateField(blank=True, null=True)), + ('target_date', models.DateField(blank=True, null=True)), + ('sequence_id', models.IntegerField(default=1, verbose_name='Issue Sequence ID')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ], + options={ + 'verbose_name': 'Issue', + 'verbose_name_plural': 'Issues', + 'db_table': 'issue', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Project', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Project Name')), + ('description', models.TextField(blank=True, verbose_name='Project Description')), + ('description_rt', models.JSONField(blank=True, null=True, verbose_name='Project Description RT')), + ('description_html', models.JSONField(blank=True, null=True, verbose_name='Project Description HTML')), + ('network', models.PositiveSmallIntegerField(choices=[(0, 'Secret'), (2, 'Public')], default=2)), + ('identifier', models.CharField(blank=True, max_length=5, null=True, verbose_name='Project Identifier')), + ('slug', models.SlugField(blank=True, max_length=100)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('default_assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='default_assignee', to=settings.AUTH_USER_MODEL)), + ('project_lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_lead', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Project', + 'verbose_name_plural': 'Projects', + 'db_table': 'project', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Team', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Team Name')), + ('description', models.TextField(blank=True, verbose_name='Team Description')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ], + options={ + 'verbose_name': 'Team', + 'verbose_name_plural': 'Teams', + 'db_table': 'team', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Workspace', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Workspace Name')), + ('logo', models.URLField(blank=True, null=True, verbose_name='Logo')), + ('slug', models.SlugField(max_length=100, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner_workspace', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Workspace', + 'verbose_name_plural': 'Workspaces', + 'db_table': 'workspace', + 'ordering': ('-created_at',), + 'unique_together': {('name', 'owner')}, + }, + ), + migrations.CreateModel( + name='WorkspaceMemberInvite', + 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)), + ('email', models.CharField(max_length=255)), + ('accepted', models.BooleanField(default=False)), + ('token', models.CharField(max_length=255)), + ('message', models.TextField(null=True)), + ('responded_at', models.DateTimeField(null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_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_member_invite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace Member Invite', + 'verbose_name_plural': 'Workspace Member Invites', + 'db_table': 'workspace_member_invite', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='View', + 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)), + ('name', models.CharField(max_length=255, verbose_name='View Name')), + ('description', models.TextField(blank=True, verbose_name='View Description')), + ('query', models.JSONField(verbose_name='View Query')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_view', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_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_view', to='db.workspace')), + ], + options={ + 'verbose_name': 'View', + 'verbose_name_plural': 'Views', + 'db_table': 'view', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TimelineIssue', + 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)), + ('sequence_id', models.FloatField(default=1.0)), + ('links', models.JSONField(blank=True, default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_timeline', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_timelineissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_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_timelineissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Timeline Issue', + 'verbose_name_plural': 'Timeline Issues', + 'db_table': 'issue_timeline', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TeamMember', + 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='teammember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to=settings.AUTH_USER_MODEL)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.team')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teammember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.workspace')), + ], + options={ + 'verbose_name': 'Team Member', + 'verbose_name_plural': 'Team Members', + 'db_table': 'team_member', + 'ordering': ('-created_at',), + 'unique_together': {('team', 'member')}, + }, + ), + migrations.AddField( + model_name='team', + name='members', + field=models.ManyToManyField(blank=True, related_name='members', through='db.TeamMember', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='team', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='team', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_team', to='db.workspace'), + ), + migrations.CreateModel( + name='State', + 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)), + ('name', models.CharField(max_length=255, verbose_name='State Name')), + ('description', models.TextField(blank=True, verbose_name='State Description')), + ('color', models.CharField(max_length=255, verbose_name='State Color')), + ('slug', models.SlugField(blank=True, max_length=100)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_state', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_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_state', to='db.workspace')), + ], + options={ + 'verbose_name': 'State', + 'verbose_name_plural': 'States', + 'db_table': 'state', + 'ordering': ('-created_at',), + 'unique_together': {('name', 'project')}, + }, + ), + migrations.CreateModel( + name='SocialLoginConnection', + 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)), + ('medium', models.CharField(choices=[('Google', 'google'), ('Github', 'github')], default=None, max_length=20)), + ('last_login_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('last_received_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('token_data', models.JSONField(null=True)), + ('extra_data', models.JSONField(null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_login_connections', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Social Login Connection', + 'verbose_name_plural': 'Social Login Connections', + 'db_table': 'social_login_connection', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Shortcut', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), + ('description', models.TextField(blank=True, verbose_name='Cycle Description')), + ('type', models.CharField(choices=[('repo', 'Repo'), ('direct', 'Direct')], max_length=255, verbose_name='Shortcut Type')), + ('url', models.URLField(blank=True, null=True, verbose_name='URL')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_shortcut', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_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_shortcut', to='db.workspace')), + ], + options={ + 'verbose_name': 'Shortcut', + 'verbose_name_plural': 'Shortcuts', + 'db_table': 'shortcut', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ProjectMemberInvite', + 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)), + ('email', models.CharField(max_length=255)), + ('accepted', models.BooleanField(default=False)), + ('token', models.CharField(max_length=255)), + ('message', models.TextField(null=True)), + ('responded_at', models.DateTimeField(null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmemberinvite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_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_projectmemberinvite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Member Invite', + 'verbose_name_plural': 'Project Member Invites', + 'db_table': 'project_member_invite', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ProjectIdentifier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('name', models.CharField(max_length=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_identifier', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Project Identifier', + 'verbose_name_plural': 'Project Identifiers', + 'db_table': 'project_identifier', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_project', to='db.workspace'), + ), + migrations.CreateModel( + name='Label', + 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)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_label', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_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_label', to='db.workspace')), + ], + options={ + 'verbose_name': 'Label', + 'verbose_name_plural': 'Labels', + 'db_table': 'label', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueSequence', + 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)), + ('sequence', models.PositiveBigIntegerField(default=1)), + ('deleted', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_sequence', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuesequence', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_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_issuesequence', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Sequence', + 'verbose_name_plural': 'Issue Sequences', + 'db_table': 'issue_sequence', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueProperty', + 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)), + ('properties', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueproperty', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueproperty', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Property', + 'verbose_name_plural': 'Issue Properties', + 'db_table': 'issue_property', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueLabel', + 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='issuelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.issue')), + ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.label')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelabel', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelabel_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_issuelabel', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Label', + 'verbose_name_plural': 'Issue Labels', + 'db_table': 'issue_label', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueComment', + 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)), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuecomment', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_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_issuecomment', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Comment', + 'verbose_name_plural': 'Issue Comments', + 'db_table': 'issue_comment', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueBlocker', + 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)), + ('block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocker_issues', to='db.issue')), + ('blocked_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_issues', to='db.issue')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueblocker', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_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_issueblocker', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Blocker', + 'verbose_name_plural': 'Issue Blockers', + 'db_table': 'issue_blocker', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueAssignee', + 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)), + ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueassignee', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_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_issueassignee', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Assignee', + 'verbose_name_plural': 'Issue Assignees', + 'db_table': 'issue_assignee', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'assignee')}, + }, + ), + migrations.CreateModel( + name='IssueActivity', + 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)), + ('verb', models.CharField(default='created', max_length=255, verbose_name='Action')), + ('field', models.CharField(blank=True, max_length=255, null=True, verbose_name='Field Name')), + ('old_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='Old Value')), + ('new_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='New Value')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_activity', to='db.issue')), + ('issue_comment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_comment', to='db.issuecomment')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueactivity', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_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_issueactivity', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Activity', + 'verbose_name_plural': 'Issue Activities', + 'db_table': 'issue_activity', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='issue', + name='assignees', + field=models.ManyToManyField(blank=True, related_name='assignee', through='db.IssueAssignee', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='issue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AddField( + model_name='issue', + name='labels', + field=models.ManyToManyField(blank=True, related_name='labels', through='db.IssueLabel', to='db.Label'), + ), + migrations.AddField( + model_name='issue', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue', to='db.issue'), + ), + migrations.AddField( + model_name='issue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issue', to='db.project'), + ), + migrations.AddField( + model_name='issue', + name='state', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='state_issue', to='db.state'), + ), + migrations.AddField( + model_name='issue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='issue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issue', to='db.workspace'), + ), + migrations.CreateModel( + name='FileAsset', + 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)), + ('attributes', models.JSONField(default=dict)), + ('asset', models.FileField(upload_to='library-assets')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'File Asset', + 'verbose_name_plural': 'File Assets', + 'db_table': 'file_asset', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='CycleIssue', + 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='cycleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.cycle')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycleissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycleissue_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_cycleissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Cycle Issue', + 'verbose_name_plural': 'Cycle Issues', + 'db_table': 'cycle_issue', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='cycle', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycle', to='db.project'), + ), + migrations.AddField( + model_name='cycle', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='cycle', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cycle', to='db.workspace'), + ), + migrations.CreateModel( + name='WorkspaceMember', + 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)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_workspace', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_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_member', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace Member', + 'verbose_name_plural': 'Workspace Members', + 'db_table': 'workspace_member', + 'ordering': ('-created_at',), + 'unique_together': {('workspace', 'member')}, + }, + ), + migrations.AlterUniqueTogether( + name='team', + unique_together={('name', 'workspace')}, + ), + migrations.CreateModel( + name='ProjectMember', + 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)), + ('comment', models.TextField(blank=True, null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='member_project', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmember', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_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_projectmember', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Member', + 'verbose_name_plural': 'Project Members', + 'db_table': 'project_member', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'member')}, + }, + ), + migrations.AlterUniqueTogether( + name='project', + unique_together={('name', 'workspace')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py new file mode 100644 index 000000000..9c25c4518 --- /dev/null +++ b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.14 on 2022-11-04 17:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='state', + options={'ordering': ('sequence',), 'verbose_name': 'State', 'verbose_name_plural': 'States'}, + ), + migrations.RenameField( + model_name='project', + old_name='description_rt', + new_name='description_text', + ), + migrations.AddField( + model_name='issueactivity', + name='actor', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activities', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='issuecomment', + name='actor', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='state', + name='sequence', + field=models.PositiveIntegerField(default=65535), + ), + migrations.AddField( + model_name='workspace', + name='company_size', + field=models.PositiveIntegerField(default=10), + ), + migrations.AddField( + model_name='workspacemember', + name='company_role', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cycleissue', + name='issue', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue'), + ), + ] diff --git a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py new file mode 100644 index 000000000..3adac35a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-11-09 17:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0002_auto_20221104_2239'), + ] + + operations = [ + migrations.AlterField( + model_name='issueproperty', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='issueproperty', + unique_together={('user', 'project')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0004_alter_state_sequence.py b/apiserver/plane/db/migrations/0004_alter_state_sequence.py new file mode 100644 index 000000000..0d4616aea --- /dev/null +++ b/apiserver/plane/db/migrations/0004_alter_state_sequence.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-10 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0003_auto_20221109_2320'), + ] + + operations = [ + migrations.AlterField( + model_name='state', + name='sequence', + field=models.FloatField(default=65535), + ), + ] diff --git a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py new file mode 100644 index 000000000..14c280e26 --- /dev/null +++ b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2022-11-14 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0004_alter_state_sequence'), + ] + + operations = [ + migrations.AlterField( + model_name='cycle', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='End Date'), + ), + migrations.AlterField( + model_name='cycle', + name='start_date', + field=models.DateField(blank=True, null=True, verbose_name='Start Date'), + ), + ] diff --git a/apiserver/plane/db/migrations/0006_alter_cycle_status.py b/apiserver/plane/db/migrations/0006_alter_cycle_status.py new file mode 100644 index 000000000..f49e263fb --- /dev/null +++ b/apiserver/plane/db/migrations/0006_alter_cycle_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-16 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0005_auto_20221114_2127'), + ] + + operations = [ + migrations.AlterField( + model_name='cycle', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('started', 'Started'), ('completed', 'Completed')], default='draft', max_length=255, verbose_name='Cycle Status'), + ), + ] diff --git a/apiserver/plane/db/migrations/0007_label_parent.py b/apiserver/plane/db/migrations/0007_label_parent.py new file mode 100644 index 000000000..03e660473 --- /dev/null +++ b/apiserver/plane/db/migrations/0007_label_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-11-28 20:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0006_alter_cycle_status'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_label', to='db.label'), + ), + ] diff --git a/apiserver/plane/db/migrations/0008_label_colour.py b/apiserver/plane/db/migrations/0008_label_colour.py new file mode 100644 index 000000000..9e630969d --- /dev/null +++ b/apiserver/plane/db/migrations/0008_label_colour.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-29 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0007_label_parent'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='colour', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/apiserver/plane/db/migrations/__init__.py b/apiserver/plane/db/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py new file mode 100644 index 000000000..b48e5c965 --- /dev/null +++ b/apiserver/plane/db/mixins.py @@ -0,0 +1,46 @@ +from django.db import models + + +class TimeAuditModel(models.Model): + + """To path when the record was created and last modified""" + + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Created At", + ) + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + + class Meta: + abstract = True + + +class UserAuditModel(models.Model): + + """To path when the record was created and last modified""" + + created_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_created_by", + verbose_name="Created By", + null=True, + ) + updated_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_updated_by", + verbose_name="Last Modified By", + null=True, + ) + + class Meta: + abstract = True + + +class AuditModel(TimeAuditModel, UserAuditModel): + + """To path when the record was created and last modified""" + + class Meta: + abstract = True diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py new file mode 100644 index 000000000..0e3fdfafa --- /dev/null +++ b/apiserver/plane/db/models/__init__.py @@ -0,0 +1,38 @@ +from .base import BaseModel + +from .user import User + +from .workspace import ( + Workspace, + WorkspaceMember, + Team, + WorkspaceMemberInvite, + TeamMember, +) + +from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier + +from .issue import ( + Issue, + IssueActivity, + TimelineIssue, + IssueProperty, + IssueComment, + IssueBlocker, + IssueLabel, + IssueAssignee, + Label, + IssueBlocker, +) + +from .asset import FileAsset + +from .social_connection import SocialLoginConnection + +from .state import State + +from .cycle import Cycle, CycleIssue + +from .shortcut import Shortcut + +from .view import View diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py new file mode 100644 index 000000000..2df1dee21 --- /dev/null +++ b/apiserver/plane/db/models/asset.py @@ -0,0 +1,24 @@ +# Django import +from django.db import models + +# Module import +from . import BaseModel + + +class FileAsset(BaseModel): + """ + A file asset. + """ + + attributes = models.JSONField(default=dict) + asset = models.FileField(upload_to="library-assets") + + class Meta: + verbose_name = "File Asset" + verbose_name_plural = "File Assets" + db_table = "file_asset" + ordering = ("-created_at",) + + def __str__(self): + return self.asset + diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py new file mode 100644 index 000000000..d0531e881 --- /dev/null +++ b/apiserver/plane/db/models/base.py @@ -0,0 +1,39 @@ +import uuid + +# Django imports +from django.db import models + +# Third party imports +from crum import get_current_user + +# Module imports +from ..mixins import AuditModel + + +class BaseModel(AuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + user = get_current_user() + + if user is None or user.is_anonymous: + self.created_by = None + self.updated_by = None + super(BaseModel, self).save(*args, **kwargs) + else: + # Check if the model is being created or updated + if self._state.adding: + # If created only set created_by value: set updated_by to None + self.created_by = user + self.updated_by = None + # If updated only set updated_by value don't touch created_by + self.updated_by = user + super(BaseModel, self).save(*args, **kwargs) + + def __str__(self): + return str(self.id) diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py new file mode 100644 index 000000000..8d7858445 --- /dev/null +++ b/apiserver/plane/db/models/cycle.py @@ -0,0 +1,61 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Cycle(ProjectBaseModel): + STATUS_CHOICES = ( + ("draft", "Draft"), + ("started", "Started"), + ("completed", "Completed"), + ) + name = models.CharField(max_length=255, verbose_name="Cycle Name") + description = models.TextField(verbose_name="Cycle Description", blank=True) + start_date = models.DateField(verbose_name="Start Date", blank=True, null=True) + end_date = models.DateField(verbose_name="End Date", blank=True, null=True) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owned_by_cycle", + ) + status = models.CharField( + max_length=255, + verbose_name="Cycle Status", + choices=STATUS_CHOICES, + default="draft", + ) + + class Meta: + verbose_name = "Cycle" + verbose_name_plural = "Cycles" + db_table = "cycle" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the cycle""" + return f"{self.name} <{self.project.name}>" + + +class CycleIssue(ProjectBaseModel): + """ + Cycle Issues + """ + + issue = models.OneToOneField( + "db.Issue", on_delete=models.CASCADE, related_name="issue_cycle" + ) + cycle = models.ForeignKey( + Cycle, on_delete=models.CASCADE, related_name="issue_cycle" + ) + + class Meta: + verbose_name = "Cycle Issue" + verbose_name_plural = "Cycle Issues" + db_table = "cycle_issue" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py new file mode 100644 index 000000000..908fc00e6 --- /dev/null +++ b/apiserver/plane/db/models/issue.py @@ -0,0 +1,296 @@ +# Django imports +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +# Module imports +from . import ProjectBaseModel + +# TODO: Handle identifiers for Bulk Inserts - nk +class Issue(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_issue", + ) + name = models.CharField(max_length=255, verbose_name="Issue Name") + description = models.JSONField(verbose_name="Issue Description", blank=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + null=True, + blank=True, + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="assignee", + through="IssueAssignee", + through_fields=("issue", "assignee"), + ) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + labels = models.ManyToManyField( + "db.Label", blank=True, related_name="labels", through="IssueLabel" + ) + + class Meta: + verbose_name = "Issue" + verbose_name_plural = "Issues" + db_table = "issue" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # This means that the model isn't saved to the database yet + if self._state.adding: + # Get the maximum display_id value from the database + + last_id = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + # aggregate can return None! Check it first. + # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it + if last_id is not None: + self.sequence_id = last_id + 1 + if self.state is None: + try: + from plane.db.models import State + + self.state, created = State.objects.get_or_create( + project=self.project, name="Backlog" + ) + except ImportError: + pass + super(Issue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the issue""" + return f"{self.name} <{self.project.name}>" + + +class IssueBlocker(ProjectBaseModel): + block = models.ForeignKey( + Issue, related_name="blocker_issues", on_delete=models.CASCADE + ) + blocked_by = models.ForeignKey( + Issue, related_name="blocked_issues", on_delete=models.CASCADE + ) + + class Meta: + verbose_name = "Issue Blocker" + verbose_name_plural = "Issue Blockers" + db_table = "issue_blocker" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.block.name} {self.blocked_by.name}" + + +class IssueAssignee(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_assignee" + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_assignee", + ) + + class Meta: + unique_together = ["issue", "assignee"] + verbose_name = "Issue Assignee" + verbose_name_plural = "Issue Assignees" + db_table = "issue_assignee" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.assignee.email}" + + +class IssueActivity(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_activity" + ) + verb = models.CharField(max_length=255, verbose_name="Action", default="created") + field = models.CharField( + max_length=255, verbose_name="Field Name", blank=True, null=True + ) + old_value = models.CharField( + max_length=255, verbose_name="Old Value", blank=True, null=True + ) + new_value = models.CharField( + max_length=255, verbose_name="New Value", blank=True, null=True + ) + + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue_comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.SET_NULL, + related_name="issue_comment", + null=True, + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activities", + ) + + class Meta: + verbose_name = "Issue Activity" + verbose_name_plural = "Issue Activities" + db_table = "issue_activity" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class TimelineIssue(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_timeline" + ) + sequence_id = models.FloatField(default=1.0) + links = models.JSONField(default=dict, blank=True) + + class Meta: + verbose_name = "Timeline Issue" + verbose_name_plural = "Timeline Issues" + db_table = "issue_timeline" + ordering = ("-created_at",) + + def __str__(self): + """Return project of the project member""" + return str(self.issue) + + +class IssueComment(ProjectBaseModel): + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE) + # System can also create comment + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comments", + null=True, + ) + + class Meta: + verbose_name = "Issue Comment" + verbose_name_plural = "Issue Comments" + db_table = "issue_comment" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class IssueProperty(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_property_user", + ) + properties = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue Property" + verbose_name_plural = "Issue Properties" + db_table = "issue_property" + ordering = ("-created_at",) + unique_together = ["user", "project"] + + def __str__(self): + """Return properties status of the issue""" + return str(self.user) + + +class Label(ProjectBaseModel): + + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_label", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + colour = models.CharField(max_length=255, blank=True) + + class Meta: + verbose_name = "Label" + verbose_name_plural = "Labels" + db_table = "label" + ordering = ("-created_at",) + + def __str__(self): + return str(self.name) + + +class IssueLabel(ProjectBaseModel): + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="label_issue" + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="label_issue" + ) + + class Meta: + verbose_name = "Issue Label" + verbose_name_plural = "Issue Labels" + db_table = "issue_label" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.label.name}" + + +class IssueSequence(ProjectBaseModel): + + issue = models.ForeignKey( + Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True + ) + sequence = models.PositiveBigIntegerField(default=1) + deleted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Issue Sequence" + verbose_name_plural = "Issue Sequences" + db_table = "issue_sequence" + ordering = ("-created_at",) + + +# TODO: Find a better method to save the model +@receiver(post_save, sender=Issue) +def create_issue_sequence(sender, instance, created, **kwargs): + + if created: + IssueSequence.objects.create( + issue=instance, sequence=instance.sequence_id, project=instance.project + ) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py new file mode 100644 index 000000000..9e8913dd5 --- /dev/null +++ b/apiserver/plane/db/models/project.py @@ -0,0 +1,142 @@ +# Django imports +from django.db import models +from django.conf import settings +from django.template.defaultfilters import slugify +from django.db.models.signals import post_save +from django.dispatch import receiver + +# Modeule imports +from plane.db.mixins import AuditModel + +# Module imports +from . import BaseModel + +ROLE_CHOICES = ( + (20, "Admin"), + (15, "Member"), + (10, "Viewer"), + (5, "Guest"), +) + + +class Project(BaseModel): + + NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) + name = models.CharField(max_length=255, verbose_name="Project Name") + description = models.TextField(verbose_name="Project Description", blank=True) + description_text = models.JSONField( + verbose_name="Project Description RT", blank=True, null=True + ) + description_html = models.JSONField( + verbose_name="Project Description HTML", blank=True, null=True + ) + network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) + workspace = models.ForeignKey( + "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" + ) + identifier = models.CharField( + max_length=5, verbose_name="Project Identifier", null=True, blank=True + ) + slug = models.SlugField(max_length=100, blank=True) + default_assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="default_assignee", + null=True, + blank=True, + ) + project_lead = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_lead", + null=True, + blank=True, + ) + + def __str__(self): + """Return name of the project""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = ["name", "workspace"] + verbose_name = "Project" + verbose_name_plural = "Projects" + db_table = "project" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + self.identifier = self.identifier.strip().upper() + return super().save(*args, **kwargs) + + +class ProjectBaseModel(BaseModel): + + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="project_%(class)s" + ) + workspace = models.ForeignKey( + "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + self.workspace = self.project.workspace + super(ProjectBaseModel, self).save(*args, **kwargs) + + +class ProjectMemberInvite(ProjectBaseModel): + email = models.CharField(max_length=255) + accepted = models.BooleanField(default=False) + token = models.CharField(max_length=255) + message = models.TextField(null=True) + responded_at = models.DateTimeField(null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + + class Meta: + verbose_name = "Project Member Invite" + verbose_name_plural = "Project Member Invites" + db_table = "project_member_invite" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.email} {self.accepted}" + + +class ProjectMember(ProjectBaseModel): + + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="member_project", + ) + comment = models.TextField(blank=True, null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + + class Meta: + unique_together = ["project", "member"] + verbose_name = "Project Member" + verbose_name_plural = "Project Members" + db_table = "project_member" + ordering = ("-created_at",) + + def __str__(self): + """Return members of the project""" + return f"{self.member.email} <{self.project.name}>" + + +class ProjectIdentifier(AuditModel): + project = models.OneToOneField( + Project, on_delete=models.CASCADE, related_name="project_identifier" + ) + name = models.CharField(max_length=10) + + class Meta: + verbose_name = "Project Identifier" + verbose_name_plural = "Project Identifiers" + db_table = "project_identifier" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/shortcut.py b/apiserver/plane/db/models/shortcut.py new file mode 100644 index 000000000..833fb4a5c --- /dev/null +++ b/apiserver/plane/db/models/shortcut.py @@ -0,0 +1,26 @@ +# Django imports +from django.db import models + + +# Module imports +from . import ProjectBaseModel + + +class Shortcut(ProjectBaseModel): + TYPE_CHOICES = (("repo", "Repo"), ("direct", "Direct")) + name = models.CharField(max_length=255, verbose_name="Cycle Name") + description = models.TextField(verbose_name="Cycle Description", blank=True) + type = models.CharField( + max_length=255, verbose_name="Shortcut Type", choices=TYPE_CHOICES + ) + url = models.URLField(verbose_name="URL", blank=True, null=True) + + class Meta: + verbose_name = "Shortcut" + verbose_name_plural = "Shortcuts" + db_table = "shortcut" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the shortcut""" + return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py new file mode 100644 index 000000000..20f3385a0 --- /dev/null +++ b/apiserver/plane/db/models/social_connection.py @@ -0,0 +1,34 @@ +# Django imports +from django.db import models +from django.conf import settings +from django.utils import timezone + +# Module import +from . import BaseModel + + +class SocialLoginConnection(BaseModel): + medium = models.CharField( + max_length=20, + choices=(("Google", "google"), ("Github", "github")), + default=None, + ) + last_login_at = models.DateTimeField(default=timezone.now, null=True) + last_received_at = models.DateTimeField(default=timezone.now, null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_login_connections", + ) + token_data = models.JSONField(null=True) + extra_data = models.JSONField(null=True) + + class Meta: + verbose_name = "Social Login Connection" + verbose_name_plural = "Social Login Connections" + db_table = "social_login_connection" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the user and medium""" + return f"{self.medium} <{self.user.email}>" diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py new file mode 100644 index 000000000..42142364f --- /dev/null +++ b/apiserver/plane/db/models/state.py @@ -0,0 +1,29 @@ +# Django imports +from django.db import models +from django.template.defaultfilters import slugify + +# Module imports +from . import ProjectBaseModel + + +class State(ProjectBaseModel): + name = models.CharField(max_length=255, verbose_name="State Name") + description = models.TextField(verbose_name="State Description", blank=True) + color = models.CharField(max_length=255, verbose_name="State Color") + slug = models.SlugField(max_length=100, blank=True) + sequence = models.FloatField(default=65535) + + def __str__(self): + """Return name of the state""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project"] + verbose_name = "State" + verbose_name_plural = "States" + db_table = "state" + ordering = ("sequence",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + return super().save(*args, **kwargs) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py new file mode 100644 index 000000000..7efa4be49 --- /dev/null +++ b/apiserver/plane/db/models/user.py @@ -0,0 +1,126 @@ +# Python imports +from enum import unique +import uuid + +# Django imports +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin +from django.utils import timezone +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from sentry_sdk import capture_exception + + +class User(AbstractBaseUser, PermissionsMixin): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + username = models.CharField(max_length=128, unique=True) + + # user fields + mobile_number = models.CharField(max_length=255, blank=True, null=True) + email = models.CharField(max_length=255, null=True, blank=True, unique=True) + first_name = models.CharField(max_length=255, blank=True) + last_name = models.CharField(max_length=255, blank=True) + avatar = models.CharField(max_length=255, blank=True) + + # tracking metrics + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + last_location = models.CharField(max_length=255, blank=True) + created_location = models.CharField(max_length=255, blank=True) + + # the is' es + is_superuser = models.BooleanField(default=False) + is_managed = models.BooleanField(default=False) + is_password_expired = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_email_verified = models.BooleanField(default=False) + is_password_autoset = models.BooleanField(default=False) + is_onboarded = models.BooleanField(default=False) + + token = models.CharField(max_length=64, blank=True) + + billing_address_country = models.CharField(max_length=255, default="INDIA") + billing_address = models.JSONField(null=True) + has_billing_address = models.BooleanField(default=False) + + user_timezone = models.CharField(max_length=255, default="Asia/Kolkata") + + last_active = models.DateTimeField(default=timezone.now, null=True) + last_login_time = models.DateTimeField(null=True) + last_logout_time = models.DateTimeField(null=True) + last_login_ip = models.CharField(max_length=255, blank=True) + last_logout_ip = models.CharField(max_length=255, blank=True) + last_login_medium = models.CharField( + max_length=20, + default="email", + ) + last_login_uagent = models.TextField(blank=True) + token_updated_at = models.DateTimeField(null=True) + last_workspace_id = models.UUIDField(null=True) + + USERNAME_FIELD = "email" + + REQUIRED_FIELDS = ["username"] + + objects = UserManager() + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + db_table = "user" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.username} <{self.email}>" + + def save(self, *args, **kwargs): + self.email = self.email.lower().strip() + self.mobile_number = self.mobile_number + + if self.token_updated_at is not None: + self.token = uuid.uuid4().hex + uuid.uuid4().hex + self.token_updated_at = timezone.now() + + if self.is_superuser: + self.is_staff = True + + super(User, self).save(*args, **kwargs) + + +@receiver(post_save, sender=User) +def send_welcome_email(sender, instance, created, **kwargs): + try: + if created: + first_name = instance.first_name.capitalize() + to_email = instance.email + from_email_string = f"Team Plane " + + subject = f"Welcome {first_name}!" + + context = {"first_name": first_name, "email": instance.email} + + html_content = render_to_string( + "emails/auth/user_welcome_email.html", context + ) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives( + subject, text_content, from_email_string, [to_email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py new file mode 100644 index 000000000..db7234cc2 --- /dev/null +++ b/apiserver/plane/db/models/view.py @@ -0,0 +1,22 @@ +# Django imports +from django.db import models + + +# Module import +from . import ProjectBaseModel + + +class View(ProjectBaseModel): + name = models.CharField(max_length=255, verbose_name="View Name") + description = models.TextField(verbose_name="View Description", blank=True) + query = models.JSONField(verbose_name="View Query") + + class Meta: + verbose_name = "View" + verbose_name_plural = "Views" + db_table = "view" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the View""" + return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py new file mode 100644 index 000000000..3474a8735 --- /dev/null +++ b/apiserver/plane/db/models/workspace.py @@ -0,0 +1,134 @@ +# Django imports +from django.db import models +from django.template.defaultfilters import slugify +from django.conf import settings + +# Module imports +from . import BaseModel + + +ROLE_CHOICES = ( + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), +) + + +class Workspace(BaseModel): + name = models.CharField(max_length=255, 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) + + def __str__(self): + """Return name of the Workspace""" + return self.name + + class Meta: + unique_together = ["name", "owner"] + verbose_name = "Workspace" + verbose_name_plural = "Workspaces" + db_table = "workspace" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + return super().save(*args, **kwargs) + + +class WorkspaceMember(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" + ) + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="member_workspace", + ) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + company_role = models.TextField(null=True, blank=True) + + class Meta: + unique_together = ["workspace", "member"] + verbose_name = "Workspace Member" + verbose_name_plural = "Workspace Members" + db_table = "workspace_member" + ordering = ("-created_at",) + + def __str__(self): + """Return members of the workspace""" + return f"{self.member.email} <{self.workspace.name}>" + + +class WorkspaceMemberInvite(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" + ) + email = models.CharField(max_length=255) + accepted = models.BooleanField(default=False) + token = models.CharField(max_length=255) + message = models.TextField(null=True) + responded_at = models.DateTimeField(null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + + class Meta: + verbose_name = "Workspace Member Invite" + verbose_name_plural = "Workspace Member Invites" + db_table = "workspace_member_invite" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.email} {self.accepted}" + + +class Team(BaseModel): + name = models.CharField(max_length=255, verbose_name="Team Name") + description = models.TextField(verbose_name="Team Description", blank=True) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="members", + through="TeamMember", + through_fields=("team", "member"), + ) + workspace = models.ForeignKey( + Workspace, on_delete=models.CASCADE, related_name="workspace_team" + ) + + def __str__(self): + """Return name of the team""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = ["name", "workspace"] + verbose_name = "Team" + verbose_name_plural = "Teams" + db_table = "team" + ordering = ("-created_at",) + + +class TeamMember(BaseModel): + + workspace = models.ForeignKey( + Workspace, on_delete=models.CASCADE, related_name="team_member" + ) + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member") + member = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member" + ) + + def __str__(self): + return self.team.name + + class Meta: + unique_together = ["team", "member"] + verbose_name = "Team Member" + verbose_name_plural = "Team Members" + db_table = "team_member" + ordering = ("-created_at",) diff --git a/apiserver/plane/middleware/__init__.py b/apiserver/plane/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/middleware/apps.py b/apiserver/plane/middleware/apps.py new file mode 100644 index 000000000..3da4958c1 --- /dev/null +++ b/apiserver/plane/middleware/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class Middleware(AppConfig): + name = 'plane.middleware' diff --git a/apiserver/plane/middleware/user_middleware.py b/apiserver/plane/middleware/user_middleware.py new file mode 100644 index 000000000..60dee9b73 --- /dev/null +++ b/apiserver/plane/middleware/user_middleware.py @@ -0,0 +1,33 @@ +import jwt +import pytz +from django.conf import settings +from django.utils import timezone +from plane.db.models import User + + +class UserMiddleware(object): + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + try: + if request.headers.get("Authorization"): + authorization_header = request.headers.get("Authorization") + access_token = authorization_header.split(" ")[1] + decoded = jwt.decode( + access_token, settings.SECRET_KEY, algorithms=["HS256"] + ) + id = decoded['user_id'] + user = User.objects.get(id=id) + user.last_active = timezone.now() + user.token_updated_at = None + user.save() + timezone.activate(pytz.timezone(user.user_timezone)) + except Exception as e: + print(e) + + response = self.get_response(request) + + return response diff --git a/apiserver/plane/settings/__init__.py b/apiserver/plane/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py new file mode 100644 index 000000000..00a1f5e3f --- /dev/null +++ b/apiserver/plane/settings/common.py @@ -0,0 +1,208 @@ +import os +import datetime +from datetime import timedelta + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +SECRET_KEY = os.environ.get('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + # Inhouse apps + "plane.analytics", + "plane.api", + "plane.bgtasks", + "plane.db", + "plane.utils", + "plane.web", + "plane.middleware", + # Third-party things + "rest_framework", + "rest_framework.authtoken", + "rest_framework_simplejwt.token_blacklist", + "corsheaders", + "taggit", + "fieldsignals", + "django_rq", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + # "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "crum.CurrentRequestUserMiddleware", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), +} + +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", # default + # "guardian.backends.ObjectPermissionBackend", +) + +ROOT_URLCONF = "plane.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + "templates", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +JWT_AUTH = { + "JWT_ENCODE_HANDLER": "rest_framework_jwt.utils.jwt_encode_handler", + "JWT_DECODE_HANDLER": "rest_framework_jwt.utils.jwt_decode_handler", + "JWT_PAYLOAD_HANDLER": "rest_framework_jwt.utils.jwt_payload_handler", + "JWT_PAYLOAD_GET_USER_ID_HANDLER": "rest_framework_jwt.utils.jwt_get_user_id_from_payload_handler", + "JWT_RESPONSE_PAYLOAD_HANDLER": "rest_framework_jwt.utils.jwt_response_payload_handler", + "JWT_SECRET_KEY": SECRET_KEY, + "JWT_GET_USER_SECRET_KEY": None, + "JWT_PUBLIC_KEY": None, + "JWT_PRIVATE_KEY": None, + "JWT_ALGORITHM": "HS256", + "JWT_VERIFY": True, + "JWT_VERIFY_EXPIRATION": True, + "JWT_LEEWAY": 0, + "JWT_EXPIRATION_DELTA": datetime.timedelta(seconds=604800), + "JWT_AUDIENCE": None, + "JWT_ISSUER": None, + "JWT_ALLOW_REFRESH": False, + "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=7), + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_AUTH_COOKIE": None, +} + +WSGI_APPLICATION = "plane.wsgi.application" + +# Django Sites + +SITE_ID = 1 + +# User Model +AUTH_USER_MODEL = "db.User" + +# Database + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static") +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) + +# Media Settings +MEDIA_ROOT = "mediafiles" +MEDIA_URL = "/media/" + + +# Internationalization + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "Asia/Kolkata" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +# Host for sending e-mail. +EMAIL_HOST = os.environ.get("EMAIL_HOST") +# Port for sending e-mail. +EMAIL_PORT = 587 +# Optional SMTP authentication information for EMAIL_HOST. +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), + "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), +} diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py new file mode 100644 index 000000000..e1434c219 --- /dev/null +++ b/apiserver/plane/settings/local.py @@ -0,0 +1,67 @@ +"""Development settings and globals.""" + +from __future__ import absolute_import + +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + + +from .common import * # noqa + +DEBUG = True + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": "", + "PASSWORD": "", + "HOST": "", + } +} + + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + +INSTALLED_APPS += ("debug_toolbar",) + +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) + +DEBUG_TOOLBAR_PATCH_SETTINGS = False + +INTERNAL_IPS = ("127.0.0.1",) + +CORS_ORIGIN_ALLOW_ALL = True + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True, + environment="local", + traces_sample_rate=0.7, +) + +REDIS_HOST = "localhost" +REDIS_PORT = 6379 +REDIS_URL = False + +RQ_QUEUES = { + "default": { + "HOST": "localhost", + "PORT": 6379, + "DB": 0, + "DEFAULT_TIMEOUT": 360, + }, +} + +WEB_URL = "http://localhost:3000" \ No newline at end of file diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py new file mode 100644 index 000000000..b98545292 --- /dev/null +++ b/apiserver/plane/settings/production.py @@ -0,0 +1,188 @@ +"""Production settings and globals.""" +from plane.settings.local import WEB_URL +from .common import * # noqa + +import dj_database_url +from urllib.parse import urlparse +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +# Database +DEBUG = True +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": "", + "PASSWORD": "", + "HOST": "", + } +} + +# CORS WHITELIST ON PROD +CORS_ORIGIN_WHITELIST = [ + # "https://example.com", + # "https://sub.example.com", + # "http://localhost:8080", + # "http://127.0.0.1:9000" +] +# Parse database configuration from $DATABASE_URL +DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = ["*"] + +# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. +CORS_ALLOW_ALL_ORIGINS = True + +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + traces_sample_rate=1, + send_default_pii=True, + environment="production", +) + +# The AWS region to connect to. +AWS_REGION = os.environ.get("AWS_REGION") + +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" + + +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" + +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = "" + +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" + +# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication +# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, +# and their permissions will be set to "public-read". +AWS_S3_BUCKET_AUTH = False + +# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` +# is True. It also affects the "Cache-Control" header of the files. +# Important: Changing this setting will not affect existing files. +AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + +# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting +# cannot be used with `AWS_S3_BUCKET_AUTH`. +AWS_S3_PUBLIC_URL = "" + +# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you +# understand the consequences before enabling. +# Important: Changing this setting will not affect existing files. +AWS_S3_REDUCED_REDUNDANCY = False + +# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_DISPOSITION = "" + +# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_LANGUAGE = "" + +# A mapping of custom metadata for each file. Each value can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_METADATA = {} + +# If True, then files will be stored using AES256 server-side encryption. +# If this is a string value (e.g., "aws:kms"), that encryption type will be used. +# Otherwise, server-side encryption is not be enabled. +# Important: Changing this setting will not affect existing files. +AWS_S3_ENCRYPT_KEY = False + +# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. +# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). +# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + +# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their +# compressed size is smaller than their uncompressed size. +# Important: Changing this setting will not affect existing files. +AWS_S3_GZIP = True + +# The signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None + +# If True, then files with the same name will overwrite each other. By default it's set to False to have +# extra characters appended. +AWS_S3_FILE_OVERWRITE = False + +# AWS Settings End + + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + + +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 + + +REDIS_URL = os.environ.get("REDIS_URL") + +REDIS_TLS_URL = os.environ.get("REDIS_TLS_URL") + +if REDIS_TLS_URL: + REDIS_URL = REDIS_TLS_URL + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } +} + +RQ_QUEUES = { + "default": { + "USE_REDIS_CACHE": "default", + } +} + +WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py new file mode 100644 index 000000000..c1eb1b59a --- /dev/null +++ b/apiserver/plane/settings/redis.py @@ -0,0 +1,23 @@ +import redis +import os +from django.conf import settings +from urllib.parse import urlparse + + +def redis_instance(): + if settings.REDIS_URL: + tls_url = os.environ.get("REDIS_TLS_URL", False) + url = urlparse(settings.REDIS_URL) + if tls_url: + url = urlparse(tls_url) + ri = redis.Redis( + host=url.hostname, + port=url.port, + password=url.password, + ssl=True, + ssl_cert_reqs=None, + ) + else: + ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) + + return ri diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py new file mode 100644 index 000000000..fb349a3d8 --- /dev/null +++ b/apiserver/plane/settings/staging.py @@ -0,0 +1,188 @@ +"""Production settings and globals.""" +from plane.settings.local import WEB_URL +from .common import * # noqa + +import dj_database_url +from urllib.parse import urlparse +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +# Database +DEBUG = False +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": "", + "PASSWORD": "", + "HOST": "", + } +} + +# CORS WHITELIST ON PROD +CORS_ORIGIN_WHITELIST = [ + # "https://example.com", + # "https://sub.example.com", + # "http://localhost:8080", + # "http://127.0.0.1:9000" +] +# Parse database configuration from $DATABASE_URL +DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = ["*"] + +# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. +CORS_ALLOW_ALL_ORIGINS = True + +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + traces_sample_rate=1, + send_default_pii=True, + environment="staging", +) + +# The AWS region to connect to. +AWS_REGION = os.environ.get("AWS_REGION") + +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" + + +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" + +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = "" + +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" + +# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication +# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, +# and their permissions will be set to "public-read". +AWS_S3_BUCKET_AUTH = False + +# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` +# is True. It also affects the "Cache-Control" header of the files. +# Important: Changing this setting will not affect existing files. +AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + +# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting +# cannot be used with `AWS_S3_BUCKET_AUTH`. +AWS_S3_PUBLIC_URL = "" + +# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you +# understand the consequences before enabling. +# Important: Changing this setting will not affect existing files. +AWS_S3_REDUCED_REDUNDANCY = False + +# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_DISPOSITION = "" + +# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_LANGUAGE = "" + +# A mapping of custom metadata for each file. Each value can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_METADATA = {} + +# If True, then files will be stored using AES256 server-side encryption. +# If this is a string value (e.g., "aws:kms"), that encryption type will be used. +# Otherwise, server-side encryption is not be enabled. +# Important: Changing this setting will not affect existing files. +AWS_S3_ENCRYPT_KEY = False + +# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. +# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). +# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + +# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their +# compressed size is smaller than their uncompressed size. +# Important: Changing this setting will not affect existing files. +AWS_S3_GZIP = True + +# The signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None + +# If True, then files with the same name will overwrite each other. By default it's set to False to have +# extra characters appended. +AWS_S3_FILE_OVERWRITE = False + +# AWS Settings End + + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + + +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 + + +REDIS_URL = os.environ.get("REDIS_URL") + +REDIS_TLS_URL = os.environ.get("REDIS_TLS_URL") + +if REDIS_TLS_URL: + REDIS_URL = REDIS_TLS_URL + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } +} + +RQ_QUEUES = { + "default": { + "USE_REDIS_CACHE": "default", + } +} + +WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py new file mode 100644 index 000000000..6c009997c --- /dev/null +++ b/apiserver/plane/settings/test.py @@ -0,0 +1,45 @@ +from __future__ import absolute_import + +from .common import * # noqa + +DEBUG = True + +INSTALLED_APPS.append("plane.tests") + +if os.environ.get('GITHUB_WORKFLOW'): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'github_actions', + 'USER': 'postgres', + 'PASSWORD': 'postgres', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'plane_test', + 'USER': 'postgres', + 'PASSWORD': 'password123', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } + } + +REDIS_HOST = "localhost" +REDIS_PORT = 6379 +REDIS_URL = False + +RQ_QUEUES = { + "default": { + "HOST": "localhost", + "PORT": 6379, + "DB": 0, + "DEFAULT_TIMEOUT": 360, + }, +} + +WEB_URL = "http://localhost:3000" diff --git a/apiserver/plane/static/css/style.css b/apiserver/plane/static/css/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/static/humans.txt b/apiserver/plane/static/humans.txt new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/static/js/script.js b/apiserver/plane/static/js/script.js new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py new file mode 100644 index 000000000..f77d5060c --- /dev/null +++ b/apiserver/plane/tests/__init__.py @@ -0,0 +1 @@ +from .api import * \ No newline at end of file diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py new file mode 100644 index 000000000..fec51303a --- /dev/null +++ b/apiserver/plane/tests/api/base.py @@ -0,0 +1,34 @@ +# Third party imports +from rest_framework.test import APITestCase, APIClient + +# Module imports +from plane.db.models import User +from plane.api.views.authentication import get_tokens_for_user + + +class BaseAPITest(APITestCase): + def setUp(self): + self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") + + +class AuthenticatedAPITest(BaseAPITest): + def setUp(self): + super().setUp() + + ## Create Dummy User + self.email = "user@plane.so" + user = User.objects.create(email=self.email) + user.set_password("user@123") + user.save() + + # Set user + self.user = user + + # Set Up User ID + self.user_id = user.id + + access_token, _ = get_tokens_for_user(user) + self.access_token = access_token + + # Set Up Authentication Token + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token) diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py new file mode 100644 index 000000000..51a36ba2f --- /dev/null +++ b/apiserver/plane/tests/api/test_asset.py @@ -0,0 +1 @@ +# TODO: Tests for File Asset Uploads \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py new file mode 100644 index 000000000..92ad92d6e --- /dev/null +++ b/apiserver/plane/tests/api/test_auth_extended.py @@ -0,0 +1 @@ +#TODO: Tests for ChangePassword and other Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py new file mode 100644 index 000000000..4fc46e008 --- /dev/null +++ b/apiserver/plane/tests/api/test_authentication.py @@ -0,0 +1,209 @@ +# Python import +import json + +# Django imports +from django.urls import reverse + +# Third Party imports +from rest_framework import status +from .base import BaseAPITest + +# Module imports +from plane.db.models import User +from plane.settings.redis import redis_instance + + +class SignInEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("sign-in") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_validity(self): + + url = reverse("sign-in") + response = self.client.post( + url, {"email": "useremail.com", "password": "user@123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Please provide a valid email address."} + ) + + def test_password_validity(self): + url = reverse("sign-in") + response = self.client.post( + url, {"email": "user@plane.so", "password": "user123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data, + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + ) + + def test_user_exists(self): + url = reverse("sign-in") + response = self.client.post( + url, {"email": "user@email.so", "password": "user123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data, + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + ) + + def test_user_login(self): + url = reverse("sign-in") + + response = self.client.post( + url, + {"email": "user@plane.so", "password": "user@123"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data.get("user").get("email"), + "user@plane.so", + ) + + +class MagicLinkGenerateEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("magic-generate") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_validity(self): + + url = reverse("magic-generate") + response = self.client.post(url, {"email": "useremail.com"}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Please provide a valid email address."} + ) + + def test_magic_generate(self): + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = self.client.post(url, {"email": "user@plane.so"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_max_generate_attempt(self): + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + response = self.client.post( + url, + {"email": "user@plane.so"}, + format="json", + ) + + response = self.client.post( + url, + {"email": "user@plane.so"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Max attempts exhausted. Please try again later."} + ) + + +class MagicSignInEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("magic-sign-in") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"error": "User token and key are required"}) + + def test_expired_invalid_magic_link(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "The magic code/link has expired please try again"} + ) + + def test_invalid_magic_code(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + ## Create Token + url = reverse("magic-generate") + self.client.post(url, {"email": "user@plane.so"}, format="json") + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Your login code was incorrect. Please try again."} + ) + + def test_magic_code_sign_in(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + ## Create Token + url = reverse("magic-generate") + self.client.post(url, {"email": "user@plane.so"}, format="json") + + # Get the token + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": token}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data.get("user").get("email"), + "user@plane.so", + ) diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py new file mode 100644 index 000000000..04c2d6ba2 --- /dev/null +++ b/apiserver/plane/tests/api/test_cycle.py @@ -0,0 +1 @@ +# TODO: Write Test for Cycle Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py new file mode 100644 index 000000000..3e59613e0 --- /dev/null +++ b/apiserver/plane/tests/api/test_issue.py @@ -0,0 +1 @@ +# TODO: Write Test for Issue Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py new file mode 100644 index 000000000..e70e4fccb --- /dev/null +++ b/apiserver/plane/tests/api/test_oauth.py @@ -0,0 +1 @@ +#TODO: Tests for OAuth Authentication Endpoint \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py new file mode 100644 index 000000000..c4750f9b8 --- /dev/null +++ b/apiserver/plane/tests/api/test_people.py @@ -0,0 +1 @@ +# TODO: Write Test for people Endpoint \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py new file mode 100644 index 000000000..49dae5581 --- /dev/null +++ b/apiserver/plane/tests/api/test_project.py @@ -0,0 +1 @@ +# TODO: Write Tests for project endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py new file mode 100644 index 000000000..2e939af70 --- /dev/null +++ b/apiserver/plane/tests/api/test_shortcut.py @@ -0,0 +1 @@ +# TODO: Write Test for shortcuts \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py new file mode 100644 index 000000000..ef9631bc2 --- /dev/null +++ b/apiserver/plane/tests/api/test_state.py @@ -0,0 +1 @@ +# TODO: Wrote test for state endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py new file mode 100644 index 000000000..c8864f28a --- /dev/null +++ b/apiserver/plane/tests/api/test_view.py @@ -0,0 +1 @@ +# TODO: Write test for view endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py new file mode 100644 index 000000000..a1da2997a --- /dev/null +++ b/apiserver/plane/tests/api/test_workspace.py @@ -0,0 +1,43 @@ +# Django imports +from django.urls import reverse + +# Third party import +from rest_framework import status + +# Module imports +from .base import AuthenticatedAPITest +from plane.db.models import Workspace, WorkspaceMember + + +class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): + def setUp(self): + super().setUp() + + def test_create_workspace(self): + + url = reverse("workspace") + + # Test with empty data + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Test with valid data + response = self.client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Workspace.objects.count(), 1) + # Check if the member is created + self.assertEqual(WorkspaceMember.objects.count(), 1) + + # Check other values + workspace = Workspace.objects.get(pk=response.data["id"]) + workspace_member = WorkspaceMember.objects.get(workspace=workspace, member_id=self.user_id) + self.assertEqual(workspace.owner_id, self.user_id) + self.assertEqual(workspace_member.role, 20) + + # Create a already existing workspace + response = self.client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_410_GONE) diff --git a/apiserver/plane/tests/apps.py b/apiserver/plane/tests/apps.py new file mode 100644 index 000000000..577414e63 --- /dev/null +++ b/apiserver/plane/tests/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.tests" diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py new file mode 100644 index 000000000..3dfde38bd --- /dev/null +++ b/apiserver/plane/urls.py @@ -0,0 +1,29 @@ +"""plane URL Configuration + +""" + +# from django.contrib import admin +from django.urls import path +from django.views.generic import TemplateView + +from django.conf import settings +from django.conf.urls import include, url + +# from django.conf.urls.static import static + +urlpatterns = [ + # path("admin/", admin.site.urls), + path("", TemplateView.as_view(template_name="index.html")), + path("api/", include("plane.api.urls")), + path("", include("plane.web.urls")), +] +# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + +if settings.DEBUG: + import debug_toolbar + + urlpatterns = [ + url(r"^__debug__/", include(debug_toolbar.urls)), + ] + urlpatterns diff --git a/apiserver/plane/utils/__init__.py b/apiserver/plane/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py new file mode 100644 index 000000000..1a0d2924e --- /dev/null +++ b/apiserver/plane/utils/imports.py @@ -0,0 +1,20 @@ +import pkgutil +import six + + +def import_submodules(context, root_module, path): + """ + Import all submodules and register them in the ``context`` namespace. + >>> import_submodules(locals(), __name__, __path__) + """ + for loader, module_name, is_pkg in pkgutil.walk_packages( + path, + root_module + + '.'): + # this causes a Runtime error with model conflicts + # module = loader.find_module(module_name).load_module(module_name) + module = __import__(module_name, globals(), locals(), ['__name__']) + for k, v in six.iteritems(vars(module)): + if not k.startswith('_'): + context[k] = v + context[module_name] = module \ No newline at end of file diff --git a/apiserver/plane/utils/ip_address.py b/apiserver/plane/utils/ip_address.py new file mode 100644 index 000000000..29a2fa520 --- /dev/null +++ b/apiserver/plane/utils/ip_address.py @@ -0,0 +1,7 @@ +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip \ No newline at end of file diff --git a/apiserver/plane/utils/markdown.py b/apiserver/plane/utils/markdown.py new file mode 100644 index 000000000..15d5b4dce --- /dev/null +++ b/apiserver/plane/utils/markdown.py @@ -0,0 +1,3 @@ +import mistune + +markdown = mistune.Markdown() \ No newline at end of file diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py new file mode 100644 index 000000000..b3c50abd1 --- /dev/null +++ b/apiserver/plane/utils/paginator.py @@ -0,0 +1,227 @@ +from rest_framework.response import Response +from rest_framework.exceptions import ParseError +from collections.abc import Sequence +import math + + +class Cursor: + def __init__(self, value, offset=0, is_prev=False, has_results=None): + self.value = value + self.offset = int(offset) + self.is_prev = bool(is_prev) + self.has_results = has_results + + def __str__(self): + return f"{self.value}:{self.offset}:{int(self.is_prev)}" + + def __eq__(self, other): + return all( + getattr(self, attr) == getattr(other, attr) + for attr in ("value", "offset", "is_prev", "has_results") + ) + + def __repr__(self): + return "<{}: value={} offset={} is_prev={}>".format( + type(self).__name__, + self.value, + self.offset, + int(self.is_prev), + ) + + def __bool__(self): + return bool(self.has_results) + + @classmethod + def from_string(cls, value): + bits = value.split(":") + if len(bits) != 3: + raise ValueError + try: + value = float(bits[0]) if "." in bits[0] else int(bits[0]) + bits = value, int(bits[1]), int(bits[2]) + except (TypeError, ValueError): + raise ValueError + return cls(*bits) + + +class CursorResult(Sequence): + def __init__(self, results, next, prev, hits=None, max_hits=None): + self.results = results + self.next = next + self.prev = prev + self.hits = hits + self.max_hits = max_hits + + def __len__(self): + return len(self.results) + + def __iter__(self): + return iter(self.results) + + def __getitem__(self, key): + return self.results[key] + + def __repr__(self): + return f"<{type(self).__name__}: results={len(self.results)}>" + + +MAX_LIMIT = 100 + + +class BadPaginationError(Exception): + pass + + +class OffsetPaginator: + """ + The Offset paginator using the offset and limit + with cursor controls + http://example.com/api/users/?cursor=10.0.0&per_page=10 + cursor=limit,offset=page, + """ + + def __init__( + self, + queryset, + order_by=None, + max_limit=MAX_LIMIT, + max_offset=None, + on_results=None, + ): + self.key = ( + order_by + if order_by is None or isinstance(order_by, (list, tuple, set)) + else (order_by,) + ) + self.queryset = queryset + self.max_limit = max_limit + self.max_offset = max_offset + self.on_results = on_results + + def get_result(self, limit=100, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + queryset = self.queryset + if self.key: + queryset = queryset.order_by(*self.key) + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + results = list(queryset[offset:stop]) + if cursor.value != limit: + results = results[-(limit + 1) :] + + next_cursor = Cursor(limit, page + 1, False, len(results) > limit) + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + results = list(results[:limit]) + if self.on_results: + results = self.on_results(results) + + max_hits = math.ceil(queryset.count() / limit) + + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=None, + max_hits=max_hits, + ) + + +class BasePaginator: + """BasePaginator class can be inherited by any View to return a paginated view""" + + # cursor query parameter name + cursor_name = "cursor" + + # get the per page parameter from request + def get_per_page(self, request, default_per_page=100, max_per_page=100): + try: + per_page = int(request.GET.get("per_page", default_per_page)) + except ValueError: + raise ParseError(detail="Invalid per_page parameter.") + + max_per_page = max(max_per_page, default_per_page) + if per_page > max_per_page: + raise ParseError( + detail=f"Invalid per_page value. Cannot exceed {max_per_page}." + ) + + return per_page + + def paginate( + self, + request, + on_results=None, + paginator=None, + paginator_cls=OffsetPaginator, + default_per_page=100, + max_per_page=100, + cursor_cls=Cursor, + extra_stats=None, + controller=None, + **paginator_kwargs, + ): + """Paginate the request""" + assert (paginator and not paginator_kwargs) or ( + paginator_cls and paginator_kwargs + ) + + per_page = self.get_per_page(request, default_per_page, max_per_page) + + # Convert the cursor value to integer and float from string + input_cursor = None + if request.GET.get(self.cursor_name): + try: + input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name)) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") + + if not paginator: + paginator = paginator_cls(**paginator_kwargs) + + try: + cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) + except BadPaginationError as e: + raise ParseError(detail=str(e)) + + # Serialize result according to the on_result function + if on_results: + results = on_results(cursor_result.results) + else: + results = cursor_result.results + + # Add Manipulation functions to the response + if controller is not None: + results = controller(results) + else: + results = results + + # Return the response + response = Response( + { + "next_cursor": str(cursor_result.next), + "prev_cursor": str(cursor_result.prev), + "next_page_results": cursor_result.next.has_results, + "prev_page_results": cursor_result.prev.has_results, + "count": cursor_result.__len__(), + "total_pages": cursor_result.max_hits, + "extra_stats": extra_stats, + "results": results, + } + ) + + return response diff --git a/apiserver/plane/web/__init__.py b/apiserver/plane/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/web/apps.py b/apiserver/plane/web/apps.py new file mode 100644 index 000000000..76ca3c4e6 --- /dev/null +++ b/apiserver/plane/web/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebConfig(AppConfig): + name = 'plane.web' diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py new file mode 100644 index 000000000..568b99037 --- /dev/null +++ b/apiserver/plane/web/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from django.views.generic import TemplateView + +urlpatterns = [ + path('about/', TemplateView.as_view(template_name='about.html')) + +] diff --git a/apiserver/plane/web/views.py b/apiserver/plane/web/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/apiserver/plane/web/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apiserver/plane/wsgi.py b/apiserver/plane/wsgi.py new file mode 100644 index 000000000..ef3ea2780 --- /dev/null +++ b/apiserver/plane/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for plane project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', + 'plane.settings.production') + +application = get_wsgi_application() diff --git a/apiserver/requirements.txt b/apiserver/requirements.txt new file mode 100644 index 000000000..9887773e7 --- /dev/null +++ b/apiserver/requirements.txt @@ -0,0 +1,3 @@ +# This file is here because many Platforms as a Service look for +# requirements.txt in the root directory of a project. +-r requirements/production.txt \ No newline at end of file diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt new file mode 100644 index 000000000..60f7b928e --- /dev/null +++ b/apiserver/requirements/base.txt @@ -0,0 +1,29 @@ +# base requirements + +Django==3.2.14 +django-braces==1.15.0 +django-taggit==2.1.0 +psycopg2==2.9.3 +django-oauth-toolkit==2.0.0 +mistune==2.0.2 +djangorestframework==3.13.1 +redis==4.2.2 +Pillow==9.1.0 +django-nested-admin==3.4.0 +django-cors-headers==3.11.0 +whitenoise==6.0.0 +django-allauth==0.50.0 +faker==13.4.0 +django-filter==21.1 +jsonmodels==2.5.0 +djangorestframework-simplejwt==5.1.0 +sentry-sdk==1.5.12 +django-s3-storage==0.13.6 +django-crum==0.7.9 +django-guardian==2.4.0 +django-fieldsignals==0.7.0 +dj_rest_auth==2.2.5 +google-auth==2.9.1 +google-api-python-client==2.55.0 +django-rq==2.5.1 +django-redis==5.2.0 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt new file mode 100644 index 000000000..238fe63f2 --- /dev/null +++ b/apiserver/requirements/local.txt @@ -0,0 +1,3 @@ +-r base.txt + +django-debug-toolbar==3.2.4 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt new file mode 100644 index 000000000..d587c5f18 --- /dev/null +++ b/apiserver/requirements/production.txt @@ -0,0 +1,10 @@ +-r base.txt + +dj-database-url==0.5.0 +gunicorn==20.1.0 +whitenoise==6.0.0 +django-storages==1.12.3 +boto==2.49.0 +django-anymail==8.5 +twilio==7.8.2 +django-debug-toolbar==3.2.4 \ No newline at end of file diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt new file mode 100644 index 000000000..d3272191e --- /dev/null +++ b/apiserver/requirements/test.txt @@ -0,0 +1,4 @@ +-r base.txt + +pytest==7.1.2 +coverage==6.5.0 \ No newline at end of file diff --git a/apiserver/templates/about.html b/apiserver/templates/about.html new file mode 100644 index 000000000..91804cda4 --- /dev/null +++ b/apiserver/templates/about.html @@ -0,0 +1,9 @@ + +{% extends 'base.html' %} +{% load static %} + + +{% block content %} +

Hello from plane!

+

Made with Django

+{% endblock content %} \ No newline at end of file diff --git a/apiserver/templates/admin/base_site.html b/apiserver/templates/admin/base_site.html new file mode 100644 index 000000000..4fdb5e19b --- /dev/null +++ b/apiserver/templates/admin/base_site.html @@ -0,0 +1,23 @@ +{% extends "admin/base.html" %}{% load i18n %} + +{% block title %}{{ title }} | {% trans 'plane Admin' %} {% endblock %} + +{% block branding %} + + +

{% trans 'plane Admin' %}

+ + +{% endblock %}{% block nav-global %}{% endblock %} diff --git a/apiserver/templates/base.html b/apiserver/templates/base.html new file mode 100644 index 000000000..a3a2003e0 --- /dev/null +++ b/apiserver/templates/base.html @@ -0,0 +1,20 @@ +{% load static %} + + + + + + + + + Hello plane! + + + {% block content %}{% endblock content %} + + + + + + + diff --git a/apiserver/templates/emails/auth/email_verification.html b/apiserver/templates/emails/auth/email_verification.html new file mode 100644 index 000000000..ea642bbd8 --- /dev/null +++ b/apiserver/templates/emails/auth/email_verification.html @@ -0,0 +1,11 @@ + + +

+ Dear {{first_name}},

+ Welcome! Your account has been created. + Verify your email by clicking on the link below
+ {{verification_url}} + successfully.

+

+ + \ No newline at end of file diff --git a/apiserver/templates/emails/auth/forgot_password.html b/apiserver/templates/emails/auth/forgot_password.html new file mode 100644 index 000000000..7c3ae585f --- /dev/null +++ b/apiserver/templates/emails/auth/forgot_password.html @@ -0,0 +1,11 @@ + + +

+ Dear {{first_name}},

+ Welcome! Your account has been created. + Verify your email by clicking on the link below
+ {{forgot_password_url}} + successfully.

+

+ + \ No newline at end of file diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html new file mode 100644 index 000000000..d73b840ca --- /dev/null +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -0,0 +1,11 @@ + + +

+ Login,

+ Welcome! Login with the link below
+ {{magic_url}}
or enter the code.
+ {{code}} +

+

+ + \ No newline at end of file diff --git a/apiserver/templates/emails/auth/user_welcome_email.html b/apiserver/templates/emails/auth/user_welcome_email.html new file mode 100644 index 000000000..3694cd900 --- /dev/null +++ b/apiserver/templates/emails/auth/user_welcome_email.html @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html new file mode 100644 index 000000000..4c8a44cd6 --- /dev/null +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -0,0 +1,13 @@ + + +

+ Dear {{email}},

+ Welcome!
+ + {{first_name}} has invited you to join {{project_name}}!

+ + Invitation Link: {{invitation_url}} +

+

+ + \ No newline at end of file diff --git a/apiserver/templates/emails/invitations/workspace_invitation.html b/apiserver/templates/emails/invitations/workspace_invitation.html new file mode 100644 index 000000000..9840dffe4 --- /dev/null +++ b/apiserver/templates/emails/invitations/workspace_invitation.html @@ -0,0 +1,12 @@ + + +

+ Dear {{email}},

+ Welcome!
+ + {{first_name}} has invited you to join {{workspace_name}}!

+ + Invitation Link: {{invitation_url}} +

+

+ diff --git a/apiserver/templates/index.html b/apiserver/templates/index.html new file mode 100644 index 000000000..630ca66b6 --- /dev/null +++ b/apiserver/templates/index.html @@ -0,0 +1,5 @@ + {% extends 'base.html' %} {% load static %} {% block content %} +
+

Hello from plane!

+
+{% endblock content %} \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 000000000..b5f092a96 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.9.15 \ No newline at end of file From 949b62d13f6e95c863852d78b14aa1413016ac62 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 1 Dec 2022 19:18:10 +0530 Subject: [PATCH 002/104] build: create frontend and backend dockerfiles docker compose and scripts --- .dockerignore | 6 ++ apiserver/Dockerfile | 56 ++++++++++++++++++ apiserver/bin/takeoff | 6 ++ apiserver/gunicorn.config.py | 6 ++ apiserver/requirements/production.txt | 4 +- apps/{app => plane}/.prettierrc | 0 apps/plane/Dockerfile | 47 +++++++++++++++ .../components/command-palette/index.tsx | 0 .../components/command-palette/shortcuts.tsx | 0 .../components/dnd/StrictModeDroppable.tsx | 0 .../components/forms/EmailCodeForm.tsx | 0 .../components/forms/EmailPasswordForm.tsx | 0 .../project/ConfirmProjectDeletion.tsx | 0 .../components/project/CreateProjectModal.tsx | 0 .../project/SendProjectInvitationModal.tsx | 0 .../project/cycles/ConfirmCycleDeletion.tsx | 0 .../cycles/CreateUpdateCyclesModal.tsx | 0 .../components/project/cycles/CycleView.tsx | 0 .../project/issues/BoardView/SingleBoard.tsx | 0 .../project/issues/BoardView/index.tsx | 0 .../BoardView/state/ConfirmStateDeletion.tsx | 0 .../state/CreateUpdateStateModal.tsx | 0 .../project/issues/ConfirmIssueDeletion.tsx | 0 .../CreateUpdateIssueModal/SelectAssignee.tsx | 0 .../CreateUpdateIssueModal/SelectCycles.tsx | 0 .../CreateUpdateIssueModal/SelectLabels.tsx | 0 .../SelectParentIssues.tsx | 0 .../CreateUpdateIssueModal/SelectPriority.tsx | 0 .../CreateUpdateIssueModal/SelectProject.tsx | 0 .../CreateUpdateIssueModal/SelectState.tsx | 0 .../issues/CreateUpdateIssueModal/index.tsx | 0 .../project/issues/ListView/index.tsx | 0 .../project/issues/PreviewModal/index.tsx | 0 .../issue-detail/IssueDetailSidebar.tsx | 0 .../issues/issue-detail/activity/index.tsx | 0 .../issue-detail/comment/IssueCommentCard.tsx | 0 .../comment/IssueCommentSection.tsx | 0 .../issues/my-issues/ChangeStateDropdown.tsx | 0 .../components/project/memberInvitations.tsx | 0 .../components/socialbuttons/google-login.tsx | 0 .../components/toast-alert/index.tsx | 0 .../workspace/ConfirmWorkspaceDeletion.tsx | 0 .../SendWorkspaceInvitationModal.tsx | 0 .../configuration/axios-configuration.ts | 0 apps/{app => plane}/constants/api-routes.ts | 0 apps/{app => plane}/constants/common.ts | 0 apps/{app => plane}/constants/fetch-keys.ts | 0 .../constants/seo/seo-variables.ts | 0 .../constants/theme.context.constants.ts | 0 .../constants/toast.context.constants.ts | 0 .../contexts/globalContextProvider.tsx | 0 .../{app => plane}/contexts/theme.context.tsx | 0 .../{app => plane}/contexts/toast.context.tsx | 0 apps/{app => plane}/contexts/user.context.tsx | 0 apps/{app => plane}/google.d.ts | 0 apps/{app => plane}/layouts/AdminLayout.tsx | 0 apps/{app => plane}/layouts/Container.tsx | 0 apps/{app => plane}/layouts/DefaultLayout.tsx | 0 .../layouts/Navbar/DefaultTopBar.tsx | 0 .../{app => plane}/layouts/Navbar/Sidebar.tsx | 0 apps/{app => plane}/layouts/types.d.ts | 0 apps/{app => plane}/lib/cookie.ts | 0 .../lib/hoc/withAuthWrapper.tsx | 0 .../lib/hooks/useAutosizeTextArea.tsx | 0 .../lib/hooks/useIssuesProperties.tsx | 0 .../lib/hooks/useLocalStorage.tsx | 0 apps/{app => plane}/lib/hooks/useTheme.tsx | 0 apps/{app => plane}/lib/hooks/useToast.tsx | 0 apps/{app => plane}/lib/hooks/useUser.tsx | 0 apps/{app => plane}/lib/redirect.ts | 0 .../lib/services/api.service.ts | 0 .../lib/services/authentication.service.ts | 0 .../lib/services/cycles.services.ts | 0 .../lib/services/file.services.ts | 0 .../lib/services/issues.services.ts | 0 .../lib/services/project.service.ts | 0 .../lib/services/state.services.ts | 0 .../lib/services/user.service.ts | 0 .../lib/services/workspace.service.ts | 0 apps/{app => plane}/next-env.d.ts | 0 apps/{app => plane}/next.config.js | 8 +++ apps/{app => plane}/package.json | 0 apps/{app => plane}/pages/_app.tsx | 0 apps/{app => plane}/pages/api/hello.ts | 0 .../{app => plane}/pages/create-workspace.tsx | 0 apps/{app => plane}/pages/editor.tsx | 0 apps/{app => plane}/pages/index.tsx | 0 apps/{app => plane}/pages/invitations.tsx | 0 apps/{app => plane}/pages/magic-sign-in.tsx | 0 apps/{app => plane}/pages/me/my-issues.tsx | 0 apps/{app => plane}/pages/me/profile.tsx | 0 .../pages/me/workspace-invites.tsx | 0 .../pages/projects/[projectId]/cycles.tsx | 0 .../projects/[projectId]/issues/[issueId].tsx | 0 .../projects/[projectId]/issues/index.tsx | 0 .../pages/projects/[projectId]/members.tsx | 0 .../pages/projects/[projectId]/settings.tsx | 0 apps/{app => plane}/pages/projects/index.tsx | 0 apps/{app => plane}/pages/signin.tsx | 0 .../[invitationId].tsx | 0 apps/{app => plane}/pages/workspace/index.tsx | 0 .../pages/workspace/members.tsx | 0 .../pages/workspace/settings.tsx | 0 apps/{app => plane}/postcss.config.js | 0 .../public/animated-icons/uploading.json | 0 apps/{app => plane}/public/favicon.ico | Bin .../public/favicon/android-chrome-192x192.png | Bin .../public/favicon/android-chrome-512x512.png | Bin .../public/favicon/apple-touch-icon.png | Bin .../public/favicon/favicon-16x16.png | Bin .../public/favicon/favicon-32x32.png | Bin .../{app => plane}/public/favicon/favicon.ico | Bin apps/{app => plane}/public/logo.png | Bin apps/{app => plane}/public/logos/github.png | Bin apps/{app => plane}/public/sign-in-bg.png | Bin .../{app => plane}/public/sign-up-sideimg.svg | 0 apps/{app => plane}/public/site-image.png | Bin .../public/site.webmanifest.json | 0 apps/{app => plane}/public/vercel.svg | 0 apps/{app => plane}/styles/editor.css | 0 apps/{app => plane}/styles/globals.css | 0 apps/{app => plane}/tailwind.config.js | 0 apps/{app => plane}/tsconfig.json | 0 apps/{app => plane}/types/index.d.ts | 0 apps/{app => plane}/types/invitation.d.ts | 0 apps/{app => plane}/types/issues.d.ts | 0 apps/{app => plane}/types/projects.d.ts | 0 apps/{app => plane}/types/sprints.d.ts | 0 apps/{app => plane}/types/state.d.ts | 0 apps/{app => plane}/types/users.d.ts | 0 apps/{app => plane}/types/workspace.d.ts | 0 apps/{app => plane}/ui/Breadcrumbs/index.tsx | 0 apps/{app => plane}/ui/Button/index.tsx | 0 .../{app => plane}/ui/CustomListbox/index.tsx | 0 .../ui/CustomListbox/types.d.ts | 0 apps/{app => plane}/ui/EmptySpace/index.tsx | 0 apps/{app => plane}/ui/HeaderButton/index.tsx | 0 apps/{app => plane}/ui/Input/index.tsx | 0 apps/{app => plane}/ui/Input/types.d.ts | 0 apps/{app => plane}/ui/Modal/index.tsx | 0 .../{app => plane}/ui/SearchListbox/index.tsx | 0 .../ui/SearchListbox/types.d.ts | 0 apps/{app => plane}/ui/Select/index.tsx | 0 apps/{app => plane}/ui/Select/types.d.ts | 0 apps/{app => plane}/ui/Spinner/index.tsx | 0 apps/{app => plane}/ui/TextArea/index.tsx | 0 apps/{app => plane}/ui/TextArea/types.d.ts | 0 apps/{app => plane}/ui/Tooltip/index.tsx | 0 apps/{app => plane}/ui/index.ts | 0 apps/{app => plane}/yarn.lock | 0 docker-compose.yml | 54 +++++++++++++++++ 151 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 apiserver/Dockerfile create mode 100755 apiserver/bin/takeoff create mode 100644 apiserver/gunicorn.config.py rename apps/{app => plane}/.prettierrc (100%) create mode 100644 apps/plane/Dockerfile rename apps/{app => plane}/components/command-palette/index.tsx (100%) rename apps/{app => plane}/components/command-palette/shortcuts.tsx (100%) rename apps/{app => plane}/components/dnd/StrictModeDroppable.tsx (100%) rename apps/{app => plane}/components/forms/EmailCodeForm.tsx (100%) rename apps/{app => plane}/components/forms/EmailPasswordForm.tsx (100%) rename apps/{app => plane}/components/project/ConfirmProjectDeletion.tsx (100%) rename apps/{app => plane}/components/project/CreateProjectModal.tsx (100%) rename apps/{app => plane}/components/project/SendProjectInvitationModal.tsx (100%) rename apps/{app => plane}/components/project/cycles/ConfirmCycleDeletion.tsx (100%) rename apps/{app => plane}/components/project/cycles/CreateUpdateCyclesModal.tsx (100%) rename apps/{app => plane}/components/project/cycles/CycleView.tsx (100%) rename apps/{app => plane}/components/project/issues/BoardView/SingleBoard.tsx (100%) rename apps/{app => plane}/components/project/issues/BoardView/index.tsx (100%) rename apps/{app => plane}/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx (100%) rename apps/{app => plane}/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx (100%) rename apps/{app => plane}/components/project/issues/ConfirmIssueDeletion.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/SelectState.tsx (100%) rename apps/{app => plane}/components/project/issues/CreateUpdateIssueModal/index.tsx (100%) rename apps/{app => plane}/components/project/issues/ListView/index.tsx (100%) rename apps/{app => plane}/components/project/issues/PreviewModal/index.tsx (100%) rename apps/{app => plane}/components/project/issues/issue-detail/IssueDetailSidebar.tsx (100%) rename apps/{app => plane}/components/project/issues/issue-detail/activity/index.tsx (100%) rename apps/{app => plane}/components/project/issues/issue-detail/comment/IssueCommentCard.tsx (100%) rename apps/{app => plane}/components/project/issues/issue-detail/comment/IssueCommentSection.tsx (100%) rename apps/{app => plane}/components/project/issues/my-issues/ChangeStateDropdown.tsx (100%) rename apps/{app => plane}/components/project/memberInvitations.tsx (100%) rename apps/{app => plane}/components/socialbuttons/google-login.tsx (100%) rename apps/{app => plane}/components/toast-alert/index.tsx (100%) rename apps/{app => plane}/components/workspace/ConfirmWorkspaceDeletion.tsx (100%) rename apps/{app => plane}/components/workspace/SendWorkspaceInvitationModal.tsx (100%) rename apps/{app => plane}/configuration/axios-configuration.ts (100%) rename apps/{app => plane}/constants/api-routes.ts (100%) rename apps/{app => plane}/constants/common.ts (100%) rename apps/{app => plane}/constants/fetch-keys.ts (100%) rename apps/{app => plane}/constants/seo/seo-variables.ts (100%) rename apps/{app => plane}/constants/theme.context.constants.ts (100%) rename apps/{app => plane}/constants/toast.context.constants.ts (100%) rename apps/{app => plane}/contexts/globalContextProvider.tsx (100%) rename apps/{app => plane}/contexts/theme.context.tsx (100%) rename apps/{app => plane}/contexts/toast.context.tsx (100%) rename apps/{app => plane}/contexts/user.context.tsx (100%) rename apps/{app => plane}/google.d.ts (100%) rename apps/{app => plane}/layouts/AdminLayout.tsx (100%) rename apps/{app => plane}/layouts/Container.tsx (100%) rename apps/{app => plane}/layouts/DefaultLayout.tsx (100%) rename apps/{app => plane}/layouts/Navbar/DefaultTopBar.tsx (100%) rename apps/{app => plane}/layouts/Navbar/Sidebar.tsx (100%) rename apps/{app => plane}/layouts/types.d.ts (100%) rename apps/{app => plane}/lib/cookie.ts (100%) rename apps/{app => plane}/lib/hoc/withAuthWrapper.tsx (100%) rename apps/{app => plane}/lib/hooks/useAutosizeTextArea.tsx (100%) rename apps/{app => plane}/lib/hooks/useIssuesProperties.tsx (100%) rename apps/{app => plane}/lib/hooks/useLocalStorage.tsx (100%) rename apps/{app => plane}/lib/hooks/useTheme.tsx (100%) rename apps/{app => plane}/lib/hooks/useToast.tsx (100%) rename apps/{app => plane}/lib/hooks/useUser.tsx (100%) rename apps/{app => plane}/lib/redirect.ts (100%) rename apps/{app => plane}/lib/services/api.service.ts (100%) rename apps/{app => plane}/lib/services/authentication.service.ts (100%) rename apps/{app => plane}/lib/services/cycles.services.ts (100%) rename apps/{app => plane}/lib/services/file.services.ts (100%) rename apps/{app => plane}/lib/services/issues.services.ts (100%) rename apps/{app => plane}/lib/services/project.service.ts (100%) rename apps/{app => plane}/lib/services/state.services.ts (100%) rename apps/{app => plane}/lib/services/user.service.ts (100%) rename apps/{app => plane}/lib/services/workspace.service.ts (100%) rename apps/{app => plane}/next-env.d.ts (100%) rename apps/{app => plane}/next.config.js (54%) rename apps/{app => plane}/package.json (100%) rename apps/{app => plane}/pages/_app.tsx (100%) rename apps/{app => plane}/pages/api/hello.ts (100%) rename apps/{app => plane}/pages/create-workspace.tsx (100%) rename apps/{app => plane}/pages/editor.tsx (100%) rename apps/{app => plane}/pages/index.tsx (100%) rename apps/{app => plane}/pages/invitations.tsx (100%) rename apps/{app => plane}/pages/magic-sign-in.tsx (100%) rename apps/{app => plane}/pages/me/my-issues.tsx (100%) rename apps/{app => plane}/pages/me/profile.tsx (100%) rename apps/{app => plane}/pages/me/workspace-invites.tsx (100%) rename apps/{app => plane}/pages/projects/[projectId]/cycles.tsx (100%) rename apps/{app => plane}/pages/projects/[projectId]/issues/[issueId].tsx (100%) rename apps/{app => plane}/pages/projects/[projectId]/issues/index.tsx (100%) rename apps/{app => plane}/pages/projects/[projectId]/members.tsx (100%) rename apps/{app => plane}/pages/projects/[projectId]/settings.tsx (100%) rename apps/{app => plane}/pages/projects/index.tsx (100%) rename apps/{app => plane}/pages/signin.tsx (100%) rename apps/{app => plane}/pages/workspace-member-invitation/[invitationId].tsx (100%) rename apps/{app => plane}/pages/workspace/index.tsx (100%) rename apps/{app => plane}/pages/workspace/members.tsx (100%) rename apps/{app => plane}/pages/workspace/settings.tsx (100%) rename apps/{app => plane}/postcss.config.js (100%) rename apps/{app => plane}/public/animated-icons/uploading.json (100%) rename apps/{app => plane}/public/favicon.ico (100%) rename apps/{app => plane}/public/favicon/android-chrome-192x192.png (100%) rename apps/{app => plane}/public/favicon/android-chrome-512x512.png (100%) rename apps/{app => plane}/public/favicon/apple-touch-icon.png (100%) rename apps/{app => plane}/public/favicon/favicon-16x16.png (100%) rename apps/{app => plane}/public/favicon/favicon-32x32.png (100%) rename apps/{app => plane}/public/favicon/favicon.ico (100%) rename apps/{app => plane}/public/logo.png (100%) rename apps/{app => plane}/public/logos/github.png (100%) rename apps/{app => plane}/public/sign-in-bg.png (100%) rename apps/{app => plane}/public/sign-up-sideimg.svg (100%) rename apps/{app => plane}/public/site-image.png (100%) rename apps/{app => plane}/public/site.webmanifest.json (100%) rename apps/{app => plane}/public/vercel.svg (100%) rename apps/{app => plane}/styles/editor.css (100%) rename apps/{app => plane}/styles/globals.css (100%) rename apps/{app => plane}/tailwind.config.js (100%) rename apps/{app => plane}/tsconfig.json (100%) rename apps/{app => plane}/types/index.d.ts (100%) rename apps/{app => plane}/types/invitation.d.ts (100%) rename apps/{app => plane}/types/issues.d.ts (100%) rename apps/{app => plane}/types/projects.d.ts (100%) rename apps/{app => plane}/types/sprints.d.ts (100%) rename apps/{app => plane}/types/state.d.ts (100%) rename apps/{app => plane}/types/users.d.ts (100%) rename apps/{app => plane}/types/workspace.d.ts (100%) rename apps/{app => plane}/ui/Breadcrumbs/index.tsx (100%) rename apps/{app => plane}/ui/Button/index.tsx (100%) rename apps/{app => plane}/ui/CustomListbox/index.tsx (100%) rename apps/{app => plane}/ui/CustomListbox/types.d.ts (100%) rename apps/{app => plane}/ui/EmptySpace/index.tsx (100%) rename apps/{app => plane}/ui/HeaderButton/index.tsx (100%) rename apps/{app => plane}/ui/Input/index.tsx (100%) rename apps/{app => plane}/ui/Input/types.d.ts (100%) rename apps/{app => plane}/ui/Modal/index.tsx (100%) rename apps/{app => plane}/ui/SearchListbox/index.tsx (100%) rename apps/{app => plane}/ui/SearchListbox/types.d.ts (100%) rename apps/{app => plane}/ui/Select/index.tsx (100%) rename apps/{app => plane}/ui/Select/types.d.ts (100%) rename apps/{app => plane}/ui/Spinner/index.tsx (100%) rename apps/{app => plane}/ui/TextArea/index.tsx (100%) rename apps/{app => plane}/ui/TextArea/types.d.ts (100%) rename apps/{app => plane}/ui/Tooltip/index.tsx (100%) rename apps/{app => plane}/ui/index.ts (100%) rename apps/{app => plane}/yarn.lock (100%) create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..45ff21c4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +*.pyc +.env +venv +node_modules +npm-debug.log \ No newline at end of file diff --git a/apiserver/Dockerfile b/apiserver/Dockerfile new file mode 100644 index 000000000..c0057369c --- /dev/null +++ b/apiserver/Dockerfile @@ -0,0 +1,56 @@ +FROM python:3.8.14-alpine3.16 AS backend + +ENV PYTHONUNBUFFERED 1 + +WORKDIR /code + +RUN apk --update --no-cache add \ + "libpq~=14" \ + "libxslt~=1.1" \ + "nodejs-current~=18" \ + "xmlsec~=1.2" + + +COPY requirements.txt ./ +COPY requirements ./requirements +RUN apk --update --no-cache --virtual .build-deps add \ + "bash~=5.1" \ + "g++~=11.2" \ + "gcc~=11.2" \ + "cargo~=1.60" \ + "git~=2" \ + "make~=4.3" \ + "libffi-dev~=3.4" \ + "libxml2-dev~=2.9" \ + "libxslt-dev~=1.1" \ + "xmlsec-dev~=1.2" \ + "postgresql13-dev~=13" \ + "libmaxminddb~=1.6" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps + + +RUN addgroup -S plane && \ + adduser -S captain -G plane + +RUN chown captain.plane /code + +USER captain + +# Add in Django deps and generate Django's static files +COPY manage.py manage.py +COPY plane plane/ +COPY templates templates/ + +COPY gunicorn.config.py ./ + +COPY bin/takeoff ./takeoff +USER captain + +# Expose container port and run entry point script +EXPOSE 8000 + +# ENTRYPOINT [ "./takeoff" ] +CMD python manage.py migrate && python manage.py rqworker & exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff new file mode 100755 index 000000000..3ec0d34ac --- /dev/null +++ b/apiserver/bin/takeoff @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +python manage.py migrate +python manage.py rqworker & +exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/gunicorn.config.py b/apiserver/gunicorn.config.py new file mode 100644 index 000000000..67205b5ec --- /dev/null +++ b/apiserver/gunicorn.config.py @@ -0,0 +1,6 @@ +from psycogreen.gevent import patch_psycopg + + +def post_fork(server, worker): + patch_psycopg() + worker.log.info("Made Psycopg2 Green") \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index d587c5f18..231d3c0a1 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -7,4 +7,6 @@ django-storages==1.12.3 boto==2.49.0 django-anymail==8.5 twilio==7.8.2 -django-debug-toolbar==3.2.4 \ No newline at end of file +django-debug-toolbar==3.2.4 +gevent==22.10.2 +psycogreen==1.0.2 \ No newline at end of file diff --git a/apps/app/.prettierrc b/apps/plane/.prettierrc similarity index 100% rename from apps/app/.prettierrc rename to apps/plane/.prettierrc diff --git a/apps/plane/Dockerfile b/apps/plane/Dockerfile new file mode 100644 index 000000000..978b60ef4 --- /dev/null +++ b/apps/plane/Dockerfile @@ -0,0 +1,47 @@ +FROM node:alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app +RUN yarn global add turbo +COPY ./apps ./apps +COPY ./package.json ./package.json +COPY ./.eslintrc.json ./.eslintrc.json +COPY ./turbo.json ./turbo.json +COPY ./yarn.lock ./yarn.lock +RUN turbo prune --scope=plane --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM node:alpine AS installer +RUN apk add --no-cache libc6-compat +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN yarn turbo run build --filter=plane... + +FROM node:alpine AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + +COPY --from=installer /app/apps/plane/next.config.js . +COPY --from=installer /app/apps/plane/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=nextjs:nodejs /app/apps/plane/.next/standalone ./ +COPY --from=installer --chown=nextjs:nodejs /app/apps/plane/.next/static ./apps/plane/.next/static + +CMD node apps/plane/server.js \ No newline at end of file diff --git a/apps/app/components/command-palette/index.tsx b/apps/plane/components/command-palette/index.tsx similarity index 100% rename from apps/app/components/command-palette/index.tsx rename to apps/plane/components/command-palette/index.tsx diff --git a/apps/app/components/command-palette/shortcuts.tsx b/apps/plane/components/command-palette/shortcuts.tsx similarity index 100% rename from apps/app/components/command-palette/shortcuts.tsx rename to apps/plane/components/command-palette/shortcuts.tsx diff --git a/apps/app/components/dnd/StrictModeDroppable.tsx b/apps/plane/components/dnd/StrictModeDroppable.tsx similarity index 100% rename from apps/app/components/dnd/StrictModeDroppable.tsx rename to apps/plane/components/dnd/StrictModeDroppable.tsx diff --git a/apps/app/components/forms/EmailCodeForm.tsx b/apps/plane/components/forms/EmailCodeForm.tsx similarity index 100% rename from apps/app/components/forms/EmailCodeForm.tsx rename to apps/plane/components/forms/EmailCodeForm.tsx diff --git a/apps/app/components/forms/EmailPasswordForm.tsx b/apps/plane/components/forms/EmailPasswordForm.tsx similarity index 100% rename from apps/app/components/forms/EmailPasswordForm.tsx rename to apps/plane/components/forms/EmailPasswordForm.tsx diff --git a/apps/app/components/project/ConfirmProjectDeletion.tsx b/apps/plane/components/project/ConfirmProjectDeletion.tsx similarity index 100% rename from apps/app/components/project/ConfirmProjectDeletion.tsx rename to apps/plane/components/project/ConfirmProjectDeletion.tsx diff --git a/apps/app/components/project/CreateProjectModal.tsx b/apps/plane/components/project/CreateProjectModal.tsx similarity index 100% rename from apps/app/components/project/CreateProjectModal.tsx rename to apps/plane/components/project/CreateProjectModal.tsx diff --git a/apps/app/components/project/SendProjectInvitationModal.tsx b/apps/plane/components/project/SendProjectInvitationModal.tsx similarity index 100% rename from apps/app/components/project/SendProjectInvitationModal.tsx rename to apps/plane/components/project/SendProjectInvitationModal.tsx diff --git a/apps/app/components/project/cycles/ConfirmCycleDeletion.tsx b/apps/plane/components/project/cycles/ConfirmCycleDeletion.tsx similarity index 100% rename from apps/app/components/project/cycles/ConfirmCycleDeletion.tsx rename to apps/plane/components/project/cycles/ConfirmCycleDeletion.tsx diff --git a/apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx b/apps/plane/components/project/cycles/CreateUpdateCyclesModal.tsx similarity index 100% rename from apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx rename to apps/plane/components/project/cycles/CreateUpdateCyclesModal.tsx diff --git a/apps/app/components/project/cycles/CycleView.tsx b/apps/plane/components/project/cycles/CycleView.tsx similarity index 100% rename from apps/app/components/project/cycles/CycleView.tsx rename to apps/plane/components/project/cycles/CycleView.tsx diff --git a/apps/app/components/project/issues/BoardView/SingleBoard.tsx b/apps/plane/components/project/issues/BoardView/SingleBoard.tsx similarity index 100% rename from apps/app/components/project/issues/BoardView/SingleBoard.tsx rename to apps/plane/components/project/issues/BoardView/SingleBoard.tsx diff --git a/apps/app/components/project/issues/BoardView/index.tsx b/apps/plane/components/project/issues/BoardView/index.tsx similarity index 100% rename from apps/app/components/project/issues/BoardView/index.tsx rename to apps/plane/components/project/issues/BoardView/index.tsx diff --git a/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx b/apps/plane/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx similarity index 100% rename from apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx rename to apps/plane/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx diff --git a/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx b/apps/plane/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx similarity index 100% rename from apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx rename to apps/plane/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx diff --git a/apps/app/components/project/issues/ConfirmIssueDeletion.tsx b/apps/plane/components/project/issues/ConfirmIssueDeletion.tsx similarity index 100% rename from apps/app/components/project/issues/ConfirmIssueDeletion.tsx rename to apps/plane/components/project/issues/ConfirmIssueDeletion.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectState.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectState.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/SelectState.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/SelectState.tsx diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/index.tsx similarity index 100% rename from apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx rename to apps/plane/components/project/issues/CreateUpdateIssueModal/index.tsx diff --git a/apps/app/components/project/issues/ListView/index.tsx b/apps/plane/components/project/issues/ListView/index.tsx similarity index 100% rename from apps/app/components/project/issues/ListView/index.tsx rename to apps/plane/components/project/issues/ListView/index.tsx diff --git a/apps/app/components/project/issues/PreviewModal/index.tsx b/apps/plane/components/project/issues/PreviewModal/index.tsx similarity index 100% rename from apps/app/components/project/issues/PreviewModal/index.tsx rename to apps/plane/components/project/issues/PreviewModal/index.tsx diff --git a/apps/app/components/project/issues/issue-detail/IssueDetailSidebar.tsx b/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx similarity index 100% rename from apps/app/components/project/issues/issue-detail/IssueDetailSidebar.tsx rename to apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx diff --git a/apps/app/components/project/issues/issue-detail/activity/index.tsx b/apps/plane/components/project/issues/issue-detail/activity/index.tsx similarity index 100% rename from apps/app/components/project/issues/issue-detail/activity/index.tsx rename to apps/plane/components/project/issues/issue-detail/activity/index.tsx diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx b/apps/plane/components/project/issues/issue-detail/comment/IssueCommentCard.tsx similarity index 100% rename from apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx rename to apps/plane/components/project/issues/issue-detail/comment/IssueCommentCard.tsx diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx b/apps/plane/components/project/issues/issue-detail/comment/IssueCommentSection.tsx similarity index 100% rename from apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx rename to apps/plane/components/project/issues/issue-detail/comment/IssueCommentSection.tsx diff --git a/apps/app/components/project/issues/my-issues/ChangeStateDropdown.tsx b/apps/plane/components/project/issues/my-issues/ChangeStateDropdown.tsx similarity index 100% rename from apps/app/components/project/issues/my-issues/ChangeStateDropdown.tsx rename to apps/plane/components/project/issues/my-issues/ChangeStateDropdown.tsx diff --git a/apps/app/components/project/memberInvitations.tsx b/apps/plane/components/project/memberInvitations.tsx similarity index 100% rename from apps/app/components/project/memberInvitations.tsx rename to apps/plane/components/project/memberInvitations.tsx diff --git a/apps/app/components/socialbuttons/google-login.tsx b/apps/plane/components/socialbuttons/google-login.tsx similarity index 100% rename from apps/app/components/socialbuttons/google-login.tsx rename to apps/plane/components/socialbuttons/google-login.tsx diff --git a/apps/app/components/toast-alert/index.tsx b/apps/plane/components/toast-alert/index.tsx similarity index 100% rename from apps/app/components/toast-alert/index.tsx rename to apps/plane/components/toast-alert/index.tsx diff --git a/apps/app/components/workspace/ConfirmWorkspaceDeletion.tsx b/apps/plane/components/workspace/ConfirmWorkspaceDeletion.tsx similarity index 100% rename from apps/app/components/workspace/ConfirmWorkspaceDeletion.tsx rename to apps/plane/components/workspace/ConfirmWorkspaceDeletion.tsx diff --git a/apps/app/components/workspace/SendWorkspaceInvitationModal.tsx b/apps/plane/components/workspace/SendWorkspaceInvitationModal.tsx similarity index 100% rename from apps/app/components/workspace/SendWorkspaceInvitationModal.tsx rename to apps/plane/components/workspace/SendWorkspaceInvitationModal.tsx diff --git a/apps/app/configuration/axios-configuration.ts b/apps/plane/configuration/axios-configuration.ts similarity index 100% rename from apps/app/configuration/axios-configuration.ts rename to apps/plane/configuration/axios-configuration.ts diff --git a/apps/app/constants/api-routes.ts b/apps/plane/constants/api-routes.ts similarity index 100% rename from apps/app/constants/api-routes.ts rename to apps/plane/constants/api-routes.ts diff --git a/apps/app/constants/common.ts b/apps/plane/constants/common.ts similarity index 100% rename from apps/app/constants/common.ts rename to apps/plane/constants/common.ts diff --git a/apps/app/constants/fetch-keys.ts b/apps/plane/constants/fetch-keys.ts similarity index 100% rename from apps/app/constants/fetch-keys.ts rename to apps/plane/constants/fetch-keys.ts diff --git a/apps/app/constants/seo/seo-variables.ts b/apps/plane/constants/seo/seo-variables.ts similarity index 100% rename from apps/app/constants/seo/seo-variables.ts rename to apps/plane/constants/seo/seo-variables.ts diff --git a/apps/app/constants/theme.context.constants.ts b/apps/plane/constants/theme.context.constants.ts similarity index 100% rename from apps/app/constants/theme.context.constants.ts rename to apps/plane/constants/theme.context.constants.ts diff --git a/apps/app/constants/toast.context.constants.ts b/apps/plane/constants/toast.context.constants.ts similarity index 100% rename from apps/app/constants/toast.context.constants.ts rename to apps/plane/constants/toast.context.constants.ts diff --git a/apps/app/contexts/globalContextProvider.tsx b/apps/plane/contexts/globalContextProvider.tsx similarity index 100% rename from apps/app/contexts/globalContextProvider.tsx rename to apps/plane/contexts/globalContextProvider.tsx diff --git a/apps/app/contexts/theme.context.tsx b/apps/plane/contexts/theme.context.tsx similarity index 100% rename from apps/app/contexts/theme.context.tsx rename to apps/plane/contexts/theme.context.tsx diff --git a/apps/app/contexts/toast.context.tsx b/apps/plane/contexts/toast.context.tsx similarity index 100% rename from apps/app/contexts/toast.context.tsx rename to apps/plane/contexts/toast.context.tsx diff --git a/apps/app/contexts/user.context.tsx b/apps/plane/contexts/user.context.tsx similarity index 100% rename from apps/app/contexts/user.context.tsx rename to apps/plane/contexts/user.context.tsx diff --git a/apps/app/google.d.ts b/apps/plane/google.d.ts similarity index 100% rename from apps/app/google.d.ts rename to apps/plane/google.d.ts diff --git a/apps/app/layouts/AdminLayout.tsx b/apps/plane/layouts/AdminLayout.tsx similarity index 100% rename from apps/app/layouts/AdminLayout.tsx rename to apps/plane/layouts/AdminLayout.tsx diff --git a/apps/app/layouts/Container.tsx b/apps/plane/layouts/Container.tsx similarity index 100% rename from apps/app/layouts/Container.tsx rename to apps/plane/layouts/Container.tsx diff --git a/apps/app/layouts/DefaultLayout.tsx b/apps/plane/layouts/DefaultLayout.tsx similarity index 100% rename from apps/app/layouts/DefaultLayout.tsx rename to apps/plane/layouts/DefaultLayout.tsx diff --git a/apps/app/layouts/Navbar/DefaultTopBar.tsx b/apps/plane/layouts/Navbar/DefaultTopBar.tsx similarity index 100% rename from apps/app/layouts/Navbar/DefaultTopBar.tsx rename to apps/plane/layouts/Navbar/DefaultTopBar.tsx diff --git a/apps/app/layouts/Navbar/Sidebar.tsx b/apps/plane/layouts/Navbar/Sidebar.tsx similarity index 100% rename from apps/app/layouts/Navbar/Sidebar.tsx rename to apps/plane/layouts/Navbar/Sidebar.tsx diff --git a/apps/app/layouts/types.d.ts b/apps/plane/layouts/types.d.ts similarity index 100% rename from apps/app/layouts/types.d.ts rename to apps/plane/layouts/types.d.ts diff --git a/apps/app/lib/cookie.ts b/apps/plane/lib/cookie.ts similarity index 100% rename from apps/app/lib/cookie.ts rename to apps/plane/lib/cookie.ts diff --git a/apps/app/lib/hoc/withAuthWrapper.tsx b/apps/plane/lib/hoc/withAuthWrapper.tsx similarity index 100% rename from apps/app/lib/hoc/withAuthWrapper.tsx rename to apps/plane/lib/hoc/withAuthWrapper.tsx diff --git a/apps/app/lib/hooks/useAutosizeTextArea.tsx b/apps/plane/lib/hooks/useAutosizeTextArea.tsx similarity index 100% rename from apps/app/lib/hooks/useAutosizeTextArea.tsx rename to apps/plane/lib/hooks/useAutosizeTextArea.tsx diff --git a/apps/app/lib/hooks/useIssuesProperties.tsx b/apps/plane/lib/hooks/useIssuesProperties.tsx similarity index 100% rename from apps/app/lib/hooks/useIssuesProperties.tsx rename to apps/plane/lib/hooks/useIssuesProperties.tsx diff --git a/apps/app/lib/hooks/useLocalStorage.tsx b/apps/plane/lib/hooks/useLocalStorage.tsx similarity index 100% rename from apps/app/lib/hooks/useLocalStorage.tsx rename to apps/plane/lib/hooks/useLocalStorage.tsx diff --git a/apps/app/lib/hooks/useTheme.tsx b/apps/plane/lib/hooks/useTheme.tsx similarity index 100% rename from apps/app/lib/hooks/useTheme.tsx rename to apps/plane/lib/hooks/useTheme.tsx diff --git a/apps/app/lib/hooks/useToast.tsx b/apps/plane/lib/hooks/useToast.tsx similarity index 100% rename from apps/app/lib/hooks/useToast.tsx rename to apps/plane/lib/hooks/useToast.tsx diff --git a/apps/app/lib/hooks/useUser.tsx b/apps/plane/lib/hooks/useUser.tsx similarity index 100% rename from apps/app/lib/hooks/useUser.tsx rename to apps/plane/lib/hooks/useUser.tsx diff --git a/apps/app/lib/redirect.ts b/apps/plane/lib/redirect.ts similarity index 100% rename from apps/app/lib/redirect.ts rename to apps/plane/lib/redirect.ts diff --git a/apps/app/lib/services/api.service.ts b/apps/plane/lib/services/api.service.ts similarity index 100% rename from apps/app/lib/services/api.service.ts rename to apps/plane/lib/services/api.service.ts diff --git a/apps/app/lib/services/authentication.service.ts b/apps/plane/lib/services/authentication.service.ts similarity index 100% rename from apps/app/lib/services/authentication.service.ts rename to apps/plane/lib/services/authentication.service.ts diff --git a/apps/app/lib/services/cycles.services.ts b/apps/plane/lib/services/cycles.services.ts similarity index 100% rename from apps/app/lib/services/cycles.services.ts rename to apps/plane/lib/services/cycles.services.ts diff --git a/apps/app/lib/services/file.services.ts b/apps/plane/lib/services/file.services.ts similarity index 100% rename from apps/app/lib/services/file.services.ts rename to apps/plane/lib/services/file.services.ts diff --git a/apps/app/lib/services/issues.services.ts b/apps/plane/lib/services/issues.services.ts similarity index 100% rename from apps/app/lib/services/issues.services.ts rename to apps/plane/lib/services/issues.services.ts diff --git a/apps/app/lib/services/project.service.ts b/apps/plane/lib/services/project.service.ts similarity index 100% rename from apps/app/lib/services/project.service.ts rename to apps/plane/lib/services/project.service.ts diff --git a/apps/app/lib/services/state.services.ts b/apps/plane/lib/services/state.services.ts similarity index 100% rename from apps/app/lib/services/state.services.ts rename to apps/plane/lib/services/state.services.ts diff --git a/apps/app/lib/services/user.service.ts b/apps/plane/lib/services/user.service.ts similarity index 100% rename from apps/app/lib/services/user.service.ts rename to apps/plane/lib/services/user.service.ts diff --git a/apps/app/lib/services/workspace.service.ts b/apps/plane/lib/services/workspace.service.ts similarity index 100% rename from apps/app/lib/services/workspace.service.ts rename to apps/plane/lib/services/workspace.service.ts diff --git a/apps/app/next-env.d.ts b/apps/plane/next-env.d.ts similarity index 100% rename from apps/app/next-env.d.ts rename to apps/plane/next-env.d.ts diff --git a/apps/app/next.config.js b/apps/plane/next.config.js similarity index 54% rename from apps/app/next.config.js rename to apps/plane/next.config.js index 6646f0316..03e309fd0 100644 --- a/apps/app/next.config.js +++ b/apps/plane/next.config.js @@ -1,10 +1,18 @@ /** @type {import('next').NextConfig} */ +const path = require("path"); + + const nextConfig = { reactStrictMode: false, swcMinify: true, images: { domains: ["vinci-web.s3.amazonaws.com"], }, + output: 'standalone', + experimental: { + outputFileTracingRoot: path.join(__dirname, "../../"), + transpilePackages: ["ui"], + }, }; module.exports = nextConfig; diff --git a/apps/app/package.json b/apps/plane/package.json similarity index 100% rename from apps/app/package.json rename to apps/plane/package.json diff --git a/apps/app/pages/_app.tsx b/apps/plane/pages/_app.tsx similarity index 100% rename from apps/app/pages/_app.tsx rename to apps/plane/pages/_app.tsx diff --git a/apps/app/pages/api/hello.ts b/apps/plane/pages/api/hello.ts similarity index 100% rename from apps/app/pages/api/hello.ts rename to apps/plane/pages/api/hello.ts diff --git a/apps/app/pages/create-workspace.tsx b/apps/plane/pages/create-workspace.tsx similarity index 100% rename from apps/app/pages/create-workspace.tsx rename to apps/plane/pages/create-workspace.tsx diff --git a/apps/app/pages/editor.tsx b/apps/plane/pages/editor.tsx similarity index 100% rename from apps/app/pages/editor.tsx rename to apps/plane/pages/editor.tsx diff --git a/apps/app/pages/index.tsx b/apps/plane/pages/index.tsx similarity index 100% rename from apps/app/pages/index.tsx rename to apps/plane/pages/index.tsx diff --git a/apps/app/pages/invitations.tsx b/apps/plane/pages/invitations.tsx similarity index 100% rename from apps/app/pages/invitations.tsx rename to apps/plane/pages/invitations.tsx diff --git a/apps/app/pages/magic-sign-in.tsx b/apps/plane/pages/magic-sign-in.tsx similarity index 100% rename from apps/app/pages/magic-sign-in.tsx rename to apps/plane/pages/magic-sign-in.tsx diff --git a/apps/app/pages/me/my-issues.tsx b/apps/plane/pages/me/my-issues.tsx similarity index 100% rename from apps/app/pages/me/my-issues.tsx rename to apps/plane/pages/me/my-issues.tsx diff --git a/apps/app/pages/me/profile.tsx b/apps/plane/pages/me/profile.tsx similarity index 100% rename from apps/app/pages/me/profile.tsx rename to apps/plane/pages/me/profile.tsx diff --git a/apps/app/pages/me/workspace-invites.tsx b/apps/plane/pages/me/workspace-invites.tsx similarity index 100% rename from apps/app/pages/me/workspace-invites.tsx rename to apps/plane/pages/me/workspace-invites.tsx diff --git a/apps/app/pages/projects/[projectId]/cycles.tsx b/apps/plane/pages/projects/[projectId]/cycles.tsx similarity index 100% rename from apps/app/pages/projects/[projectId]/cycles.tsx rename to apps/plane/pages/projects/[projectId]/cycles.tsx diff --git a/apps/app/pages/projects/[projectId]/issues/[issueId].tsx b/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx similarity index 100% rename from apps/app/pages/projects/[projectId]/issues/[issueId].tsx rename to apps/plane/pages/projects/[projectId]/issues/[issueId].tsx diff --git a/apps/app/pages/projects/[projectId]/issues/index.tsx b/apps/plane/pages/projects/[projectId]/issues/index.tsx similarity index 100% rename from apps/app/pages/projects/[projectId]/issues/index.tsx rename to apps/plane/pages/projects/[projectId]/issues/index.tsx diff --git a/apps/app/pages/projects/[projectId]/members.tsx b/apps/plane/pages/projects/[projectId]/members.tsx similarity index 100% rename from apps/app/pages/projects/[projectId]/members.tsx rename to apps/plane/pages/projects/[projectId]/members.tsx diff --git a/apps/app/pages/projects/[projectId]/settings.tsx b/apps/plane/pages/projects/[projectId]/settings.tsx similarity index 100% rename from apps/app/pages/projects/[projectId]/settings.tsx rename to apps/plane/pages/projects/[projectId]/settings.tsx diff --git a/apps/app/pages/projects/index.tsx b/apps/plane/pages/projects/index.tsx similarity index 100% rename from apps/app/pages/projects/index.tsx rename to apps/plane/pages/projects/index.tsx diff --git a/apps/app/pages/signin.tsx b/apps/plane/pages/signin.tsx similarity index 100% rename from apps/app/pages/signin.tsx rename to apps/plane/pages/signin.tsx diff --git a/apps/app/pages/workspace-member-invitation/[invitationId].tsx b/apps/plane/pages/workspace-member-invitation/[invitationId].tsx similarity index 100% rename from apps/app/pages/workspace-member-invitation/[invitationId].tsx rename to apps/plane/pages/workspace-member-invitation/[invitationId].tsx diff --git a/apps/app/pages/workspace/index.tsx b/apps/plane/pages/workspace/index.tsx similarity index 100% rename from apps/app/pages/workspace/index.tsx rename to apps/plane/pages/workspace/index.tsx diff --git a/apps/app/pages/workspace/members.tsx b/apps/plane/pages/workspace/members.tsx similarity index 100% rename from apps/app/pages/workspace/members.tsx rename to apps/plane/pages/workspace/members.tsx diff --git a/apps/app/pages/workspace/settings.tsx b/apps/plane/pages/workspace/settings.tsx similarity index 100% rename from apps/app/pages/workspace/settings.tsx rename to apps/plane/pages/workspace/settings.tsx diff --git a/apps/app/postcss.config.js b/apps/plane/postcss.config.js similarity index 100% rename from apps/app/postcss.config.js rename to apps/plane/postcss.config.js diff --git a/apps/app/public/animated-icons/uploading.json b/apps/plane/public/animated-icons/uploading.json similarity index 100% rename from apps/app/public/animated-icons/uploading.json rename to apps/plane/public/animated-icons/uploading.json diff --git a/apps/app/public/favicon.ico b/apps/plane/public/favicon.ico similarity index 100% rename from apps/app/public/favicon.ico rename to apps/plane/public/favicon.ico diff --git a/apps/app/public/favicon/android-chrome-192x192.png b/apps/plane/public/favicon/android-chrome-192x192.png similarity index 100% rename from apps/app/public/favicon/android-chrome-192x192.png rename to apps/plane/public/favicon/android-chrome-192x192.png diff --git a/apps/app/public/favicon/android-chrome-512x512.png b/apps/plane/public/favicon/android-chrome-512x512.png similarity index 100% rename from apps/app/public/favicon/android-chrome-512x512.png rename to apps/plane/public/favicon/android-chrome-512x512.png diff --git a/apps/app/public/favicon/apple-touch-icon.png b/apps/plane/public/favicon/apple-touch-icon.png similarity index 100% rename from apps/app/public/favicon/apple-touch-icon.png rename to apps/plane/public/favicon/apple-touch-icon.png diff --git a/apps/app/public/favicon/favicon-16x16.png b/apps/plane/public/favicon/favicon-16x16.png similarity index 100% rename from apps/app/public/favicon/favicon-16x16.png rename to apps/plane/public/favicon/favicon-16x16.png diff --git a/apps/app/public/favicon/favicon-32x32.png b/apps/plane/public/favicon/favicon-32x32.png similarity index 100% rename from apps/app/public/favicon/favicon-32x32.png rename to apps/plane/public/favicon/favicon-32x32.png diff --git a/apps/app/public/favicon/favicon.ico b/apps/plane/public/favicon/favicon.ico similarity index 100% rename from apps/app/public/favicon/favicon.ico rename to apps/plane/public/favicon/favicon.ico diff --git a/apps/app/public/logo.png b/apps/plane/public/logo.png similarity index 100% rename from apps/app/public/logo.png rename to apps/plane/public/logo.png diff --git a/apps/app/public/logos/github.png b/apps/plane/public/logos/github.png similarity index 100% rename from apps/app/public/logos/github.png rename to apps/plane/public/logos/github.png diff --git a/apps/app/public/sign-in-bg.png b/apps/plane/public/sign-in-bg.png similarity index 100% rename from apps/app/public/sign-in-bg.png rename to apps/plane/public/sign-in-bg.png diff --git a/apps/app/public/sign-up-sideimg.svg b/apps/plane/public/sign-up-sideimg.svg similarity index 100% rename from apps/app/public/sign-up-sideimg.svg rename to apps/plane/public/sign-up-sideimg.svg diff --git a/apps/app/public/site-image.png b/apps/plane/public/site-image.png similarity index 100% rename from apps/app/public/site-image.png rename to apps/plane/public/site-image.png diff --git a/apps/app/public/site.webmanifest.json b/apps/plane/public/site.webmanifest.json similarity index 100% rename from apps/app/public/site.webmanifest.json rename to apps/plane/public/site.webmanifest.json diff --git a/apps/app/public/vercel.svg b/apps/plane/public/vercel.svg similarity index 100% rename from apps/app/public/vercel.svg rename to apps/plane/public/vercel.svg diff --git a/apps/app/styles/editor.css b/apps/plane/styles/editor.css similarity index 100% rename from apps/app/styles/editor.css rename to apps/plane/styles/editor.css diff --git a/apps/app/styles/globals.css b/apps/plane/styles/globals.css similarity index 100% rename from apps/app/styles/globals.css rename to apps/plane/styles/globals.css diff --git a/apps/app/tailwind.config.js b/apps/plane/tailwind.config.js similarity index 100% rename from apps/app/tailwind.config.js rename to apps/plane/tailwind.config.js diff --git a/apps/app/tsconfig.json b/apps/plane/tsconfig.json similarity index 100% rename from apps/app/tsconfig.json rename to apps/plane/tsconfig.json diff --git a/apps/app/types/index.d.ts b/apps/plane/types/index.d.ts similarity index 100% rename from apps/app/types/index.d.ts rename to apps/plane/types/index.d.ts diff --git a/apps/app/types/invitation.d.ts b/apps/plane/types/invitation.d.ts similarity index 100% rename from apps/app/types/invitation.d.ts rename to apps/plane/types/invitation.d.ts diff --git a/apps/app/types/issues.d.ts b/apps/plane/types/issues.d.ts similarity index 100% rename from apps/app/types/issues.d.ts rename to apps/plane/types/issues.d.ts diff --git a/apps/app/types/projects.d.ts b/apps/plane/types/projects.d.ts similarity index 100% rename from apps/app/types/projects.d.ts rename to apps/plane/types/projects.d.ts diff --git a/apps/app/types/sprints.d.ts b/apps/plane/types/sprints.d.ts similarity index 100% rename from apps/app/types/sprints.d.ts rename to apps/plane/types/sprints.d.ts diff --git a/apps/app/types/state.d.ts b/apps/plane/types/state.d.ts similarity index 100% rename from apps/app/types/state.d.ts rename to apps/plane/types/state.d.ts diff --git a/apps/app/types/users.d.ts b/apps/plane/types/users.d.ts similarity index 100% rename from apps/app/types/users.d.ts rename to apps/plane/types/users.d.ts diff --git a/apps/app/types/workspace.d.ts b/apps/plane/types/workspace.d.ts similarity index 100% rename from apps/app/types/workspace.d.ts rename to apps/plane/types/workspace.d.ts diff --git a/apps/app/ui/Breadcrumbs/index.tsx b/apps/plane/ui/Breadcrumbs/index.tsx similarity index 100% rename from apps/app/ui/Breadcrumbs/index.tsx rename to apps/plane/ui/Breadcrumbs/index.tsx diff --git a/apps/app/ui/Button/index.tsx b/apps/plane/ui/Button/index.tsx similarity index 100% rename from apps/app/ui/Button/index.tsx rename to apps/plane/ui/Button/index.tsx diff --git a/apps/app/ui/CustomListbox/index.tsx b/apps/plane/ui/CustomListbox/index.tsx similarity index 100% rename from apps/app/ui/CustomListbox/index.tsx rename to apps/plane/ui/CustomListbox/index.tsx diff --git a/apps/app/ui/CustomListbox/types.d.ts b/apps/plane/ui/CustomListbox/types.d.ts similarity index 100% rename from apps/app/ui/CustomListbox/types.d.ts rename to apps/plane/ui/CustomListbox/types.d.ts diff --git a/apps/app/ui/EmptySpace/index.tsx b/apps/plane/ui/EmptySpace/index.tsx similarity index 100% rename from apps/app/ui/EmptySpace/index.tsx rename to apps/plane/ui/EmptySpace/index.tsx diff --git a/apps/app/ui/HeaderButton/index.tsx b/apps/plane/ui/HeaderButton/index.tsx similarity index 100% rename from apps/app/ui/HeaderButton/index.tsx rename to apps/plane/ui/HeaderButton/index.tsx diff --git a/apps/app/ui/Input/index.tsx b/apps/plane/ui/Input/index.tsx similarity index 100% rename from apps/app/ui/Input/index.tsx rename to apps/plane/ui/Input/index.tsx diff --git a/apps/app/ui/Input/types.d.ts b/apps/plane/ui/Input/types.d.ts similarity index 100% rename from apps/app/ui/Input/types.d.ts rename to apps/plane/ui/Input/types.d.ts diff --git a/apps/app/ui/Modal/index.tsx b/apps/plane/ui/Modal/index.tsx similarity index 100% rename from apps/app/ui/Modal/index.tsx rename to apps/plane/ui/Modal/index.tsx diff --git a/apps/app/ui/SearchListbox/index.tsx b/apps/plane/ui/SearchListbox/index.tsx similarity index 100% rename from apps/app/ui/SearchListbox/index.tsx rename to apps/plane/ui/SearchListbox/index.tsx diff --git a/apps/app/ui/SearchListbox/types.d.ts b/apps/plane/ui/SearchListbox/types.d.ts similarity index 100% rename from apps/app/ui/SearchListbox/types.d.ts rename to apps/plane/ui/SearchListbox/types.d.ts diff --git a/apps/app/ui/Select/index.tsx b/apps/plane/ui/Select/index.tsx similarity index 100% rename from apps/app/ui/Select/index.tsx rename to apps/plane/ui/Select/index.tsx diff --git a/apps/app/ui/Select/types.d.ts b/apps/plane/ui/Select/types.d.ts similarity index 100% rename from apps/app/ui/Select/types.d.ts rename to apps/plane/ui/Select/types.d.ts diff --git a/apps/app/ui/Spinner/index.tsx b/apps/plane/ui/Spinner/index.tsx similarity index 100% rename from apps/app/ui/Spinner/index.tsx rename to apps/plane/ui/Spinner/index.tsx diff --git a/apps/app/ui/TextArea/index.tsx b/apps/plane/ui/TextArea/index.tsx similarity index 100% rename from apps/app/ui/TextArea/index.tsx rename to apps/plane/ui/TextArea/index.tsx diff --git a/apps/app/ui/TextArea/types.d.ts b/apps/plane/ui/TextArea/types.d.ts similarity index 100% rename from apps/app/ui/TextArea/types.d.ts rename to apps/plane/ui/TextArea/types.d.ts diff --git a/apps/app/ui/Tooltip/index.tsx b/apps/plane/ui/Tooltip/index.tsx similarity index 100% rename from apps/app/ui/Tooltip/index.tsx rename to apps/plane/ui/Tooltip/index.tsx diff --git a/apps/app/ui/index.ts b/apps/plane/ui/index.ts similarity index 100% rename from apps/app/ui/index.ts rename to apps/plane/ui/index.ts diff --git a/apps/app/yarn.lock b/apps/plane/yarn.lock similarity index 100% rename from apps/app/yarn.lock rename to apps/plane/yarn.lock diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..a36487cb5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: "3" + +services: + db: + image: postgres:12-alpine + restart: on-failure + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: plane + POSTGRES_DB: plane + POSTGRES_PASSWORD: plane + command: postgres -c 'max_connections=1000' + redis: + image: redis:6.2.7-alpine + restart: on-failure + command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb + plane_web: + container_name: plane_web + build: + context: . + dockerfile: ./apps/plane/Dockerfile + restart: always + ports: + - 3000:3000 + networks: + - app_network + + plane_api: + container_name: plane_api + build: + context: ./apiserver + dockerfile: Dockerfile + restart: always + ports: + - 8000:8000 + environment: + SENTRY_DSN: $SENTRY_DSN + WEB_URL: $WEB_URL + DATABASE_URL: postgres://plane:plane@db/plane + REDIS_URL: redis://redis + SECRET_KEY: $SECRET_KEY + networks: + - app_network + depends_on: + - db + - redis + command: python manage.py migrate && gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +networks: + app_network: + external: true + +volumes: + postgres-data: From fcebe49f48e247d6e7a8110d35882c6d234406e5 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 1 Dec 2022 22:59:06 +0530 Subject: [PATCH 003/104] build: update production file in backend remove unnecessary packages and add network configuration --- apiserver/Dockerfile | 16 +++++++--------- apiserver/plane/settings/production.py | 8 ++++---- docker-compose.yml | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apiserver/Dockerfile b/apiserver/Dockerfile index c0057369c..967e34c5f 100644 --- a/apiserver/Dockerfile +++ b/apiserver/Dockerfile @@ -1,5 +1,7 @@ FROM python:3.8.14-alpine3.16 AS backend +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 WORKDIR /code @@ -20,12 +22,7 @@ RUN apk --update --no-cache --virtual .build-deps add \ "cargo~=1.60" \ "git~=2" \ "make~=4.3" \ - "libffi-dev~=3.4" \ - "libxml2-dev~=2.9" \ - "libxslt-dev~=1.1" \ - "xmlsec-dev~=1.2" \ "postgresql13-dev~=13" \ - "libmaxminddb~=1.6" \ && \ pip install -r requirements.txt --compile --no-cache-dir \ && \ @@ -45,12 +42,13 @@ COPY plane plane/ COPY templates templates/ COPY gunicorn.config.py ./ - -COPY bin/takeoff ./takeoff +USER root +RUN apk --update --no-cache add "bash~=5.1" +COPY ./bin ./bin/ USER captain # Expose container port and run entry point script EXPOSE 8000 -# ENTRYPOINT [ "./takeoff" ] -CMD python manage.py migrate && python manage.py rqworker & exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +CMD [ "./bin/takeoff" ] + diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index b98545292..ab84c36d5 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -14,9 +14,9 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", "NAME": "plane", - "USER": "", - "PASSWORD": "", - "HOST": "", + "USER": os.environ.get('PGUSER'), + "PASSWORD": os.environ.get('PGPASSWORD'), + "HOST": os.environ.get('PGHOST'), } } @@ -28,7 +28,7 @@ CORS_ORIGIN_WHITELIST = [ # "http://127.0.0.1:9000" ] # Parse database configuration from $DATABASE_URL -DATABASES["default"] = dj_database_url.config() +# DATABASES["default"] = dj_database_url.config() SITE_ID = 1 # Enable Connection Pooling (if desired) diff --git a/docker-compose.yml b/docker-compose.yml index a36487cb5..d4267c4f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,10 +11,14 @@ services: POSTGRES_DB: plane POSTGRES_PASSWORD: plane command: postgres -c 'max_connections=1000' + networks: + - app_network redis: image: redis:6.2.7-alpine restart: on-failure command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb + networks: + - app_network plane_web: container_name: plane_web build: @@ -37,15 +41,22 @@ services: environment: SENTRY_DSN: $SENTRY_DSN WEB_URL: $WEB_URL - DATABASE_URL: postgres://plane:plane@db/plane - REDIS_URL: redis://redis + PGUSER: plane + PGPASSWORD: plane + PGHOST: db + REDIS_URL: 'redis://redis:6379/' + REDIS_HOST: redis + REDIS_PORT: 6379 SECRET_KEY: $SECRET_KEY networks: - app_network depends_on: - db - redis - command: python manage.py migrate && gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - + command: ./bin/takeoff + links: + - db + - redis networks: app_network: external: true From 7ef9ea07f0b8f685b5bb1a0dabe295659ac5f594 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 2 Dec 2022 00:28:31 +0530 Subject: [PATCH 004/104] build: merge with latest changes update dockerfile to work with pnpm --- apps/{plane => app}/.prettierrc | 0 apps/app/Dockerfile | 66 ++++++++++++++++++ .../components/command-palette/index.tsx | 0 .../components/command-palette/shortcuts.tsx | 0 .../components/dnd/StrictModeDroppable.tsx | 0 .../components/forms/EmailCodeForm.tsx | 0 .../components/forms/EmailPasswordForm.tsx | 0 .../components/lexical/config.ts | 0 .../components/lexical/editor.tsx | 0 .../components/lexical/helpers/editor.ts | 0 .../components/lexical/helpers/node.ts | 0 .../lexical/plugins/code-highlight.tsx | 0 .../components/lexical/plugins/read-only.tsx | 0 .../components/lexical/theme.ts | 0 .../lexical/toolbar/block-type-select.tsx | 0 .../lexical/toolbar/floating-link-editor.tsx | 0 .../components/lexical/toolbar/index.tsx | 0 .../components/lexical/viewer.tsx | 0 .../project/ConfirmProjectDeletion.tsx | 0 .../components/project/CreateProjectModal.tsx | 0 .../project/SendProjectInvitationModal.tsx | 0 .../project/cycles/ConfirmCycleDeletion.tsx | 0 .../cycles/CreateUpdateCyclesModal.tsx | 0 .../components/project/cycles/CycleView.tsx | 0 .../project/issues/BoardView/SingleBoard.tsx | 0 .../project/issues/BoardView/index.tsx | 0 .../BoardView/state/ConfirmStateDeletion.tsx | 0 .../state/CreateUpdateStateModal.tsx | 0 .../project/issues/ConfirmIssueDeletion.tsx | 0 .../CreateUpdateIssueModal/SelectAssignee.tsx | 0 .../CreateUpdateIssueModal/SelectCycles.tsx | 0 .../CreateUpdateIssueModal/SelectLabels.tsx | 0 .../SelectParentIssues.tsx | 0 .../CreateUpdateIssueModal/SelectPriority.tsx | 0 .../CreateUpdateIssueModal/SelectProject.tsx | 0 .../CreateUpdateIssueModal/SelectState.tsx | 0 .../issues/CreateUpdateIssueModal/index.tsx | 0 .../project/issues/ListView/index.tsx | 0 .../project/issues/PreviewModal/index.tsx | 0 .../issue-detail/IssueDetailSidebar.tsx | 0 .../issues/issue-detail/activity/index.tsx | 0 .../issue-detail/comment/IssueCommentCard.tsx | 0 .../comment/IssueCommentSection.tsx | 0 .../issues/my-issues/ChangeStateDropdown.tsx | 0 .../components/project/memberInvitations.tsx | 0 .../components/socialbuttons/google-login.tsx | 0 .../components/toast-alert/index.tsx | 0 .../workspace/ConfirmWorkspaceDeletion.tsx | 0 .../SendWorkspaceInvitationModal.tsx | 0 .../components/workspace/SingleInvitation.tsx | 0 .../configuration/axios-configuration.ts | 0 apps/{plane => app}/constants/api-routes.ts | 0 apps/{plane => app}/constants/common.ts | 0 apps/{plane => app}/constants/fetch-keys.ts | 0 .../constants/seo/seo-variables.ts | 0 .../constants/theme.context.constants.ts | 0 .../constants/toast.context.constants.ts | 0 .../contexts/globalContextProvider.tsx | 0 .../{plane => app}/contexts/theme.context.tsx | 0 .../{plane => app}/contexts/toast.context.tsx | 0 apps/{plane => app}/contexts/user.context.tsx | 0 apps/{plane => app}/google.d.ts | 0 apps/{plane => app}/layouts/AdminLayout.tsx | 0 apps/{plane => app}/layouts/Container.tsx | 0 apps/{plane => app}/layouts/DefaultLayout.tsx | 0 .../layouts/Navbar/DefaultTopBar.tsx | 0 .../{plane => app}/layouts/Navbar/Sidebar.tsx | 0 apps/{plane => app}/layouts/types.d.ts | 0 apps/{plane => app}/lib/cookie.ts | 0 .../lib/hoc/withAuthWrapper.tsx | 0 .../lib/hooks/useAutosizeTextArea.tsx | 0 .../lib/hooks/useIssuesProperties.tsx | 0 .../lib/hooks/useLocalStorage.tsx | 0 apps/{plane => app}/lib/hooks/useTheme.tsx | 0 apps/{plane => app}/lib/hooks/useToast.tsx | 0 apps/{plane => app}/lib/hooks/useUser.tsx | 0 apps/{plane => app}/lib/redirect.ts | 0 .../lib/services/api.service.ts | 0 .../lib/services/authentication.service.ts | 0 .../lib/services/cycles.services.ts | 0 .../lib/services/file.services.ts | 0 .../lib/services/issues.services.ts | 0 .../lib/services/project.service.ts | 0 .../lib/services/state.services.ts | 0 .../lib/services/user.service.ts | 0 .../lib/services/workspace.service.ts | 0 apps/{plane => app}/next-env.d.ts | 0 apps/{plane => app}/next.config.js | 0 apps/{plane => app}/package.json | 0 apps/{plane => app}/pages/_app.tsx | 0 apps/{plane => app}/pages/api/hello.ts | 0 .../{plane => app}/pages/create-workspace.tsx | 0 apps/{plane => app}/pages/editor.tsx | 0 apps/{plane => app}/pages/index.tsx | 0 apps/{plane => app}/pages/invitations.tsx | 0 apps/{plane => app}/pages/magic-sign-in.tsx | 0 apps/{plane => app}/pages/me/my-issues.tsx | 0 apps/{plane => app}/pages/me/profile.tsx | 0 .../pages/me/workspace-invites.tsx | 0 .../pages/projects/[projectId]/cycles.tsx | 0 .../projects/[projectId]/issues/[issueId].tsx | 0 .../projects/[projectId]/issues/index.tsx | 0 .../pages/projects/[projectId]/members.tsx | 0 .../pages/projects/[projectId]/settings.tsx | 0 apps/{plane => app}/pages/projects/index.tsx | 0 apps/{plane => app}/pages/signin.tsx | 0 .../[invitationId].tsx | 0 apps/{plane => app}/pages/workspace/index.tsx | 0 .../pages/workspace/members.tsx | 0 .../pages/workspace/settings.tsx | 0 apps/{plane => app}/postcss.config.js | 0 .../public/animated-icons/uploading.json | 0 apps/{plane => app}/public/favicon.ico | Bin .../public/favicon/android-chrome-192x192.png | Bin .../public/favicon/android-chrome-512x512.png | Bin .../public/favicon/apple-touch-icon.png | Bin .../public/favicon/favicon-16x16.png | Bin .../public/favicon/favicon-32x32.png | Bin .../{plane => app}/public/favicon/favicon.ico | Bin .../public/favicon/site.webmanifest | 0 apps/{plane => app}/public/logo.png | Bin apps/{plane => app}/public/logos/github.png | Bin apps/{plane => app}/public/sign-in-bg.png | Bin .../{plane => app}/public/sign-up-sideimg.svg | 0 apps/{plane => app}/public/site-image.png | Bin .../public/site.webmanifest.json | 0 apps/{plane => app}/public/vercel.svg | 0 apps/{plane => app}/styles/editor.css | 0 apps/{plane => app}/styles/globals.css | 0 apps/{plane => app}/tailwind.config.js | 0 apps/{plane => app}/tsconfig.json | 0 apps/{plane => app}/types/index.d.ts | 0 apps/{plane => app}/types/invitation.d.ts | 0 apps/{plane => app}/types/issues.d.ts | 0 apps/{plane => app}/types/projects.d.ts | 0 apps/{plane => app}/types/sprints.d.ts | 0 apps/{plane => app}/types/state.d.ts | 0 apps/{plane => app}/types/users.d.ts | 0 apps/{plane => app}/types/workspace.d.ts | 0 apps/{plane => app}/ui/Breadcrumbs/index.tsx | 0 apps/{plane => app}/ui/Button/index.tsx | 0 .../{plane => app}/ui/CustomListbox/index.tsx | 0 .../ui/CustomListbox/types.d.ts | 0 apps/{plane => app}/ui/EmptySpace/index.tsx | 0 apps/{plane => app}/ui/HeaderButton/index.tsx | 0 apps/{plane => app}/ui/Input/index.tsx | 0 apps/{plane => app}/ui/Input/types.d.ts | 0 apps/{plane => app}/ui/Modal/index.tsx | 0 .../{plane => app}/ui/SearchListbox/index.tsx | 0 .../ui/SearchListbox/types.d.ts | 0 apps/{plane => app}/ui/Select/index.tsx | 0 apps/{plane => app}/ui/Select/types.d.ts | 0 apps/{plane => app}/ui/Spinner/index.tsx | 0 apps/{plane => app}/ui/TextArea/index.tsx | 0 apps/{plane => app}/ui/TextArea/types.d.ts | 0 apps/{plane => app}/ui/Tooltip/index.tsx | 0 apps/{plane => app}/ui/index.ts | 0 apps/{plane => app}/yarn.lock | 0 apps/plane/Dockerfile | 47 ------------- {apiserver/bin => bin}/takeoff | 0 docker-compose.yml | 2 +- 161 files changed, 67 insertions(+), 48 deletions(-) rename apps/{plane => app}/.prettierrc (100%) create mode 100644 apps/app/Dockerfile rename apps/{plane => app}/components/command-palette/index.tsx (100%) rename apps/{plane => app}/components/command-palette/shortcuts.tsx (100%) rename apps/{plane => app}/components/dnd/StrictModeDroppable.tsx (100%) rename apps/{plane => app}/components/forms/EmailCodeForm.tsx (100%) rename apps/{plane => app}/components/forms/EmailPasswordForm.tsx (100%) rename apps/{plane => app}/components/lexical/config.ts (100%) rename apps/{plane => app}/components/lexical/editor.tsx (100%) rename apps/{plane => app}/components/lexical/helpers/editor.ts (100%) rename apps/{plane => app}/components/lexical/helpers/node.ts (100%) rename apps/{plane => app}/components/lexical/plugins/code-highlight.tsx (100%) rename apps/{plane => app}/components/lexical/plugins/read-only.tsx (100%) rename apps/{plane => app}/components/lexical/theme.ts (100%) rename apps/{plane => app}/components/lexical/toolbar/block-type-select.tsx (100%) rename apps/{plane => app}/components/lexical/toolbar/floating-link-editor.tsx (100%) rename apps/{plane => app}/components/lexical/toolbar/index.tsx (100%) rename apps/{plane => app}/components/lexical/viewer.tsx (100%) rename apps/{plane => app}/components/project/ConfirmProjectDeletion.tsx (100%) rename apps/{plane => app}/components/project/CreateProjectModal.tsx (100%) rename apps/{plane => app}/components/project/SendProjectInvitationModal.tsx (100%) rename apps/{plane => app}/components/project/cycles/ConfirmCycleDeletion.tsx (100%) rename apps/{plane => app}/components/project/cycles/CreateUpdateCyclesModal.tsx (100%) rename apps/{plane => app}/components/project/cycles/CycleView.tsx (100%) rename apps/{plane => app}/components/project/issues/BoardView/SingleBoard.tsx (100%) rename apps/{plane => app}/components/project/issues/BoardView/index.tsx (100%) rename apps/{plane => app}/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx (100%) rename apps/{plane => app}/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx (100%) rename apps/{plane => app}/components/project/issues/ConfirmIssueDeletion.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/SelectState.tsx (100%) rename apps/{plane => app}/components/project/issues/CreateUpdateIssueModal/index.tsx (100%) rename apps/{plane => app}/components/project/issues/ListView/index.tsx (100%) rename apps/{plane => app}/components/project/issues/PreviewModal/index.tsx (100%) rename apps/{plane => app}/components/project/issues/issue-detail/IssueDetailSidebar.tsx (100%) rename apps/{plane => app}/components/project/issues/issue-detail/activity/index.tsx (100%) rename apps/{plane => app}/components/project/issues/issue-detail/comment/IssueCommentCard.tsx (100%) rename apps/{plane => app}/components/project/issues/issue-detail/comment/IssueCommentSection.tsx (100%) rename apps/{plane => app}/components/project/issues/my-issues/ChangeStateDropdown.tsx (100%) rename apps/{plane => app}/components/project/memberInvitations.tsx (100%) rename apps/{plane => app}/components/socialbuttons/google-login.tsx (100%) rename apps/{plane => app}/components/toast-alert/index.tsx (100%) rename apps/{plane => app}/components/workspace/ConfirmWorkspaceDeletion.tsx (100%) rename apps/{plane => app}/components/workspace/SendWorkspaceInvitationModal.tsx (100%) rename apps/{plane => app}/components/workspace/SingleInvitation.tsx (100%) rename apps/{plane => app}/configuration/axios-configuration.ts (100%) rename apps/{plane => app}/constants/api-routes.ts (100%) rename apps/{plane => app}/constants/common.ts (100%) rename apps/{plane => app}/constants/fetch-keys.ts (100%) rename apps/{plane => app}/constants/seo/seo-variables.ts (100%) rename apps/{plane => app}/constants/theme.context.constants.ts (100%) rename apps/{plane => app}/constants/toast.context.constants.ts (100%) rename apps/{plane => app}/contexts/globalContextProvider.tsx (100%) rename apps/{plane => app}/contexts/theme.context.tsx (100%) rename apps/{plane => app}/contexts/toast.context.tsx (100%) rename apps/{plane => app}/contexts/user.context.tsx (100%) rename apps/{plane => app}/google.d.ts (100%) rename apps/{plane => app}/layouts/AdminLayout.tsx (100%) rename apps/{plane => app}/layouts/Container.tsx (100%) rename apps/{plane => app}/layouts/DefaultLayout.tsx (100%) rename apps/{plane => app}/layouts/Navbar/DefaultTopBar.tsx (100%) rename apps/{plane => app}/layouts/Navbar/Sidebar.tsx (100%) rename apps/{plane => app}/layouts/types.d.ts (100%) rename apps/{plane => app}/lib/cookie.ts (100%) rename apps/{plane => app}/lib/hoc/withAuthWrapper.tsx (100%) rename apps/{plane => app}/lib/hooks/useAutosizeTextArea.tsx (100%) rename apps/{plane => app}/lib/hooks/useIssuesProperties.tsx (100%) rename apps/{plane => app}/lib/hooks/useLocalStorage.tsx (100%) rename apps/{plane => app}/lib/hooks/useTheme.tsx (100%) rename apps/{plane => app}/lib/hooks/useToast.tsx (100%) rename apps/{plane => app}/lib/hooks/useUser.tsx (100%) rename apps/{plane => app}/lib/redirect.ts (100%) rename apps/{plane => app}/lib/services/api.service.ts (100%) rename apps/{plane => app}/lib/services/authentication.service.ts (100%) rename apps/{plane => app}/lib/services/cycles.services.ts (100%) rename apps/{plane => app}/lib/services/file.services.ts (100%) rename apps/{plane => app}/lib/services/issues.services.ts (100%) rename apps/{plane => app}/lib/services/project.service.ts (100%) rename apps/{plane => app}/lib/services/state.services.ts (100%) rename apps/{plane => app}/lib/services/user.service.ts (100%) rename apps/{plane => app}/lib/services/workspace.service.ts (100%) rename apps/{plane => app}/next-env.d.ts (100%) rename apps/{plane => app}/next.config.js (100%) rename apps/{plane => app}/package.json (100%) rename apps/{plane => app}/pages/_app.tsx (100%) rename apps/{plane => app}/pages/api/hello.ts (100%) rename apps/{plane => app}/pages/create-workspace.tsx (100%) rename apps/{plane => app}/pages/editor.tsx (100%) rename apps/{plane => app}/pages/index.tsx (100%) rename apps/{plane => app}/pages/invitations.tsx (100%) rename apps/{plane => app}/pages/magic-sign-in.tsx (100%) rename apps/{plane => app}/pages/me/my-issues.tsx (100%) rename apps/{plane => app}/pages/me/profile.tsx (100%) rename apps/{plane => app}/pages/me/workspace-invites.tsx (100%) rename apps/{plane => app}/pages/projects/[projectId]/cycles.tsx (100%) rename apps/{plane => app}/pages/projects/[projectId]/issues/[issueId].tsx (100%) rename apps/{plane => app}/pages/projects/[projectId]/issues/index.tsx (100%) rename apps/{plane => app}/pages/projects/[projectId]/members.tsx (100%) rename apps/{plane => app}/pages/projects/[projectId]/settings.tsx (100%) rename apps/{plane => app}/pages/projects/index.tsx (100%) rename apps/{plane => app}/pages/signin.tsx (100%) rename apps/{plane => app}/pages/workspace-member-invitation/[invitationId].tsx (100%) rename apps/{plane => app}/pages/workspace/index.tsx (100%) rename apps/{plane => app}/pages/workspace/members.tsx (100%) rename apps/{plane => app}/pages/workspace/settings.tsx (100%) rename apps/{plane => app}/postcss.config.js (100%) rename apps/{plane => app}/public/animated-icons/uploading.json (100%) rename apps/{plane => app}/public/favicon.ico (100%) rename apps/{plane => app}/public/favicon/android-chrome-192x192.png (100%) rename apps/{plane => app}/public/favicon/android-chrome-512x512.png (100%) rename apps/{plane => app}/public/favicon/apple-touch-icon.png (100%) rename apps/{plane => app}/public/favicon/favicon-16x16.png (100%) rename apps/{plane => app}/public/favicon/favicon-32x32.png (100%) rename apps/{plane => app}/public/favicon/favicon.ico (100%) rename apps/{plane => app}/public/favicon/site.webmanifest (100%) rename apps/{plane => app}/public/logo.png (100%) rename apps/{plane => app}/public/logos/github.png (100%) rename apps/{plane => app}/public/sign-in-bg.png (100%) rename apps/{plane => app}/public/sign-up-sideimg.svg (100%) rename apps/{plane => app}/public/site-image.png (100%) rename apps/{plane => app}/public/site.webmanifest.json (100%) rename apps/{plane => app}/public/vercel.svg (100%) rename apps/{plane => app}/styles/editor.css (100%) rename apps/{plane => app}/styles/globals.css (100%) rename apps/{plane => app}/tailwind.config.js (100%) rename apps/{plane => app}/tsconfig.json (100%) rename apps/{plane => app}/types/index.d.ts (100%) rename apps/{plane => app}/types/invitation.d.ts (100%) rename apps/{plane => app}/types/issues.d.ts (100%) rename apps/{plane => app}/types/projects.d.ts (100%) rename apps/{plane => app}/types/sprints.d.ts (100%) rename apps/{plane => app}/types/state.d.ts (100%) rename apps/{plane => app}/types/users.d.ts (100%) rename apps/{plane => app}/types/workspace.d.ts (100%) rename apps/{plane => app}/ui/Breadcrumbs/index.tsx (100%) rename apps/{plane => app}/ui/Button/index.tsx (100%) rename apps/{plane => app}/ui/CustomListbox/index.tsx (100%) rename apps/{plane => app}/ui/CustomListbox/types.d.ts (100%) rename apps/{plane => app}/ui/EmptySpace/index.tsx (100%) rename apps/{plane => app}/ui/HeaderButton/index.tsx (100%) rename apps/{plane => app}/ui/Input/index.tsx (100%) rename apps/{plane => app}/ui/Input/types.d.ts (100%) rename apps/{plane => app}/ui/Modal/index.tsx (100%) rename apps/{plane => app}/ui/SearchListbox/index.tsx (100%) rename apps/{plane => app}/ui/SearchListbox/types.d.ts (100%) rename apps/{plane => app}/ui/Select/index.tsx (100%) rename apps/{plane => app}/ui/Select/types.d.ts (100%) rename apps/{plane => app}/ui/Spinner/index.tsx (100%) rename apps/{plane => app}/ui/TextArea/index.tsx (100%) rename apps/{plane => app}/ui/TextArea/types.d.ts (100%) rename apps/{plane => app}/ui/Tooltip/index.tsx (100%) rename apps/{plane => app}/ui/index.ts (100%) rename apps/{plane => app}/yarn.lock (100%) delete mode 100644 apps/plane/Dockerfile rename {apiserver/bin => bin}/takeoff (100%) diff --git a/apps/plane/.prettierrc b/apps/app/.prettierrc similarity index 100% rename from apps/plane/.prettierrc rename to apps/app/.prettierrc diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile new file mode 100644 index 000000000..967096ba3 --- /dev/null +++ b/apps/app/Dockerfile @@ -0,0 +1,66 @@ +FROM node:alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +RUN apk add curl + +RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm; + +ENV PNPM_HOME="pnpm" +ENV PATH="${PATH}:./pnpm" + +COPY ./apps ./apps +COPY ./package.json ./package.json +COPY ./.eslintrc.json ./.eslintrc.json +COPY ./turbo.json ./turbo.json +COPY ./yarn.lock ./yarn.lock +COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY ./pnpm-lock.yaml ./pnpm-lock.yaml + +RUN pnpm add -g turbo +RUN turbo prune --scope=app --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM node:alpine AS installer + +RUN apk add curl + +RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm; + +ENV PNPM_HOME="pnpm" +ENV PATH="${PATH}:./pnpm" + +RUN apk add --no-cache libc6-compat +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json +RUN pnpm turbo run build --filter=app... + +FROM node:alpine AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 plane +RUN adduser --system --uid 1001 captain +USER captain + +COPY --from=installer /app/apps/app/next.config.js . +COPY --from=installer /app/apps/app/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ +COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static + +CMD node apps/app/server.js \ No newline at end of file diff --git a/apps/plane/components/command-palette/index.tsx b/apps/app/components/command-palette/index.tsx similarity index 100% rename from apps/plane/components/command-palette/index.tsx rename to apps/app/components/command-palette/index.tsx diff --git a/apps/plane/components/command-palette/shortcuts.tsx b/apps/app/components/command-palette/shortcuts.tsx similarity index 100% rename from apps/plane/components/command-palette/shortcuts.tsx rename to apps/app/components/command-palette/shortcuts.tsx diff --git a/apps/plane/components/dnd/StrictModeDroppable.tsx b/apps/app/components/dnd/StrictModeDroppable.tsx similarity index 100% rename from apps/plane/components/dnd/StrictModeDroppable.tsx rename to apps/app/components/dnd/StrictModeDroppable.tsx diff --git a/apps/plane/components/forms/EmailCodeForm.tsx b/apps/app/components/forms/EmailCodeForm.tsx similarity index 100% rename from apps/plane/components/forms/EmailCodeForm.tsx rename to apps/app/components/forms/EmailCodeForm.tsx diff --git a/apps/plane/components/forms/EmailPasswordForm.tsx b/apps/app/components/forms/EmailPasswordForm.tsx similarity index 100% rename from apps/plane/components/forms/EmailPasswordForm.tsx rename to apps/app/components/forms/EmailPasswordForm.tsx diff --git a/apps/plane/components/lexical/config.ts b/apps/app/components/lexical/config.ts similarity index 100% rename from apps/plane/components/lexical/config.ts rename to apps/app/components/lexical/config.ts diff --git a/apps/plane/components/lexical/editor.tsx b/apps/app/components/lexical/editor.tsx similarity index 100% rename from apps/plane/components/lexical/editor.tsx rename to apps/app/components/lexical/editor.tsx diff --git a/apps/plane/components/lexical/helpers/editor.ts b/apps/app/components/lexical/helpers/editor.ts similarity index 100% rename from apps/plane/components/lexical/helpers/editor.ts rename to apps/app/components/lexical/helpers/editor.ts diff --git a/apps/plane/components/lexical/helpers/node.ts b/apps/app/components/lexical/helpers/node.ts similarity index 100% rename from apps/plane/components/lexical/helpers/node.ts rename to apps/app/components/lexical/helpers/node.ts diff --git a/apps/plane/components/lexical/plugins/code-highlight.tsx b/apps/app/components/lexical/plugins/code-highlight.tsx similarity index 100% rename from apps/plane/components/lexical/plugins/code-highlight.tsx rename to apps/app/components/lexical/plugins/code-highlight.tsx diff --git a/apps/plane/components/lexical/plugins/read-only.tsx b/apps/app/components/lexical/plugins/read-only.tsx similarity index 100% rename from apps/plane/components/lexical/plugins/read-only.tsx rename to apps/app/components/lexical/plugins/read-only.tsx diff --git a/apps/plane/components/lexical/theme.ts b/apps/app/components/lexical/theme.ts similarity index 100% rename from apps/plane/components/lexical/theme.ts rename to apps/app/components/lexical/theme.ts diff --git a/apps/plane/components/lexical/toolbar/block-type-select.tsx b/apps/app/components/lexical/toolbar/block-type-select.tsx similarity index 100% rename from apps/plane/components/lexical/toolbar/block-type-select.tsx rename to apps/app/components/lexical/toolbar/block-type-select.tsx diff --git a/apps/plane/components/lexical/toolbar/floating-link-editor.tsx b/apps/app/components/lexical/toolbar/floating-link-editor.tsx similarity index 100% rename from apps/plane/components/lexical/toolbar/floating-link-editor.tsx rename to apps/app/components/lexical/toolbar/floating-link-editor.tsx diff --git a/apps/plane/components/lexical/toolbar/index.tsx b/apps/app/components/lexical/toolbar/index.tsx similarity index 100% rename from apps/plane/components/lexical/toolbar/index.tsx rename to apps/app/components/lexical/toolbar/index.tsx diff --git a/apps/plane/components/lexical/viewer.tsx b/apps/app/components/lexical/viewer.tsx similarity index 100% rename from apps/plane/components/lexical/viewer.tsx rename to apps/app/components/lexical/viewer.tsx diff --git a/apps/plane/components/project/ConfirmProjectDeletion.tsx b/apps/app/components/project/ConfirmProjectDeletion.tsx similarity index 100% rename from apps/plane/components/project/ConfirmProjectDeletion.tsx rename to apps/app/components/project/ConfirmProjectDeletion.tsx diff --git a/apps/plane/components/project/CreateProjectModal.tsx b/apps/app/components/project/CreateProjectModal.tsx similarity index 100% rename from apps/plane/components/project/CreateProjectModal.tsx rename to apps/app/components/project/CreateProjectModal.tsx diff --git a/apps/plane/components/project/SendProjectInvitationModal.tsx b/apps/app/components/project/SendProjectInvitationModal.tsx similarity index 100% rename from apps/plane/components/project/SendProjectInvitationModal.tsx rename to apps/app/components/project/SendProjectInvitationModal.tsx diff --git a/apps/plane/components/project/cycles/ConfirmCycleDeletion.tsx b/apps/app/components/project/cycles/ConfirmCycleDeletion.tsx similarity index 100% rename from apps/plane/components/project/cycles/ConfirmCycleDeletion.tsx rename to apps/app/components/project/cycles/ConfirmCycleDeletion.tsx diff --git a/apps/plane/components/project/cycles/CreateUpdateCyclesModal.tsx b/apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx similarity index 100% rename from apps/plane/components/project/cycles/CreateUpdateCyclesModal.tsx rename to apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx diff --git a/apps/plane/components/project/cycles/CycleView.tsx b/apps/app/components/project/cycles/CycleView.tsx similarity index 100% rename from apps/plane/components/project/cycles/CycleView.tsx rename to apps/app/components/project/cycles/CycleView.tsx diff --git a/apps/plane/components/project/issues/BoardView/SingleBoard.tsx b/apps/app/components/project/issues/BoardView/SingleBoard.tsx similarity index 100% rename from apps/plane/components/project/issues/BoardView/SingleBoard.tsx rename to apps/app/components/project/issues/BoardView/SingleBoard.tsx diff --git a/apps/plane/components/project/issues/BoardView/index.tsx b/apps/app/components/project/issues/BoardView/index.tsx similarity index 100% rename from apps/plane/components/project/issues/BoardView/index.tsx rename to apps/app/components/project/issues/BoardView/index.tsx diff --git a/apps/plane/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx b/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx similarity index 100% rename from apps/plane/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx rename to apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx diff --git a/apps/plane/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx b/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx similarity index 100% rename from apps/plane/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx rename to apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx diff --git a/apps/plane/components/project/issues/ConfirmIssueDeletion.tsx b/apps/app/components/project/issues/ConfirmIssueDeletion.tsx similarity index 100% rename from apps/plane/components/project/issues/ConfirmIssueDeletion.tsx rename to apps/app/components/project/issues/ConfirmIssueDeletion.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectAssignee.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectLabels.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectProject.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectState.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectState.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/SelectState.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/SelectState.tsx diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/index.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx similarity index 100% rename from apps/plane/components/project/issues/CreateUpdateIssueModal/index.tsx rename to apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx diff --git a/apps/plane/components/project/issues/ListView/index.tsx b/apps/app/components/project/issues/ListView/index.tsx similarity index 100% rename from apps/plane/components/project/issues/ListView/index.tsx rename to apps/app/components/project/issues/ListView/index.tsx diff --git a/apps/plane/components/project/issues/PreviewModal/index.tsx b/apps/app/components/project/issues/PreviewModal/index.tsx similarity index 100% rename from apps/plane/components/project/issues/PreviewModal/index.tsx rename to apps/app/components/project/issues/PreviewModal/index.tsx diff --git a/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx b/apps/app/components/project/issues/issue-detail/IssueDetailSidebar.tsx similarity index 100% rename from apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx rename to apps/app/components/project/issues/issue-detail/IssueDetailSidebar.tsx diff --git a/apps/plane/components/project/issues/issue-detail/activity/index.tsx b/apps/app/components/project/issues/issue-detail/activity/index.tsx similarity index 100% rename from apps/plane/components/project/issues/issue-detail/activity/index.tsx rename to apps/app/components/project/issues/issue-detail/activity/index.tsx diff --git a/apps/plane/components/project/issues/issue-detail/comment/IssueCommentCard.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx similarity index 100% rename from apps/plane/components/project/issues/issue-detail/comment/IssueCommentCard.tsx rename to apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx diff --git a/apps/plane/components/project/issues/issue-detail/comment/IssueCommentSection.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx similarity index 100% rename from apps/plane/components/project/issues/issue-detail/comment/IssueCommentSection.tsx rename to apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx diff --git a/apps/plane/components/project/issues/my-issues/ChangeStateDropdown.tsx b/apps/app/components/project/issues/my-issues/ChangeStateDropdown.tsx similarity index 100% rename from apps/plane/components/project/issues/my-issues/ChangeStateDropdown.tsx rename to apps/app/components/project/issues/my-issues/ChangeStateDropdown.tsx diff --git a/apps/plane/components/project/memberInvitations.tsx b/apps/app/components/project/memberInvitations.tsx similarity index 100% rename from apps/plane/components/project/memberInvitations.tsx rename to apps/app/components/project/memberInvitations.tsx diff --git a/apps/plane/components/socialbuttons/google-login.tsx b/apps/app/components/socialbuttons/google-login.tsx similarity index 100% rename from apps/plane/components/socialbuttons/google-login.tsx rename to apps/app/components/socialbuttons/google-login.tsx diff --git a/apps/plane/components/toast-alert/index.tsx b/apps/app/components/toast-alert/index.tsx similarity index 100% rename from apps/plane/components/toast-alert/index.tsx rename to apps/app/components/toast-alert/index.tsx diff --git a/apps/plane/components/workspace/ConfirmWorkspaceDeletion.tsx b/apps/app/components/workspace/ConfirmWorkspaceDeletion.tsx similarity index 100% rename from apps/plane/components/workspace/ConfirmWorkspaceDeletion.tsx rename to apps/app/components/workspace/ConfirmWorkspaceDeletion.tsx diff --git a/apps/plane/components/workspace/SendWorkspaceInvitationModal.tsx b/apps/app/components/workspace/SendWorkspaceInvitationModal.tsx similarity index 100% rename from apps/plane/components/workspace/SendWorkspaceInvitationModal.tsx rename to apps/app/components/workspace/SendWorkspaceInvitationModal.tsx diff --git a/apps/plane/components/workspace/SingleInvitation.tsx b/apps/app/components/workspace/SingleInvitation.tsx similarity index 100% rename from apps/plane/components/workspace/SingleInvitation.tsx rename to apps/app/components/workspace/SingleInvitation.tsx diff --git a/apps/plane/configuration/axios-configuration.ts b/apps/app/configuration/axios-configuration.ts similarity index 100% rename from apps/plane/configuration/axios-configuration.ts rename to apps/app/configuration/axios-configuration.ts diff --git a/apps/plane/constants/api-routes.ts b/apps/app/constants/api-routes.ts similarity index 100% rename from apps/plane/constants/api-routes.ts rename to apps/app/constants/api-routes.ts diff --git a/apps/plane/constants/common.ts b/apps/app/constants/common.ts similarity index 100% rename from apps/plane/constants/common.ts rename to apps/app/constants/common.ts diff --git a/apps/plane/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts similarity index 100% rename from apps/plane/constants/fetch-keys.ts rename to apps/app/constants/fetch-keys.ts diff --git a/apps/plane/constants/seo/seo-variables.ts b/apps/app/constants/seo/seo-variables.ts similarity index 100% rename from apps/plane/constants/seo/seo-variables.ts rename to apps/app/constants/seo/seo-variables.ts diff --git a/apps/plane/constants/theme.context.constants.ts b/apps/app/constants/theme.context.constants.ts similarity index 100% rename from apps/plane/constants/theme.context.constants.ts rename to apps/app/constants/theme.context.constants.ts diff --git a/apps/plane/constants/toast.context.constants.ts b/apps/app/constants/toast.context.constants.ts similarity index 100% rename from apps/plane/constants/toast.context.constants.ts rename to apps/app/constants/toast.context.constants.ts diff --git a/apps/plane/contexts/globalContextProvider.tsx b/apps/app/contexts/globalContextProvider.tsx similarity index 100% rename from apps/plane/contexts/globalContextProvider.tsx rename to apps/app/contexts/globalContextProvider.tsx diff --git a/apps/plane/contexts/theme.context.tsx b/apps/app/contexts/theme.context.tsx similarity index 100% rename from apps/plane/contexts/theme.context.tsx rename to apps/app/contexts/theme.context.tsx diff --git a/apps/plane/contexts/toast.context.tsx b/apps/app/contexts/toast.context.tsx similarity index 100% rename from apps/plane/contexts/toast.context.tsx rename to apps/app/contexts/toast.context.tsx diff --git a/apps/plane/contexts/user.context.tsx b/apps/app/contexts/user.context.tsx similarity index 100% rename from apps/plane/contexts/user.context.tsx rename to apps/app/contexts/user.context.tsx diff --git a/apps/plane/google.d.ts b/apps/app/google.d.ts similarity index 100% rename from apps/plane/google.d.ts rename to apps/app/google.d.ts diff --git a/apps/plane/layouts/AdminLayout.tsx b/apps/app/layouts/AdminLayout.tsx similarity index 100% rename from apps/plane/layouts/AdminLayout.tsx rename to apps/app/layouts/AdminLayout.tsx diff --git a/apps/plane/layouts/Container.tsx b/apps/app/layouts/Container.tsx similarity index 100% rename from apps/plane/layouts/Container.tsx rename to apps/app/layouts/Container.tsx diff --git a/apps/plane/layouts/DefaultLayout.tsx b/apps/app/layouts/DefaultLayout.tsx similarity index 100% rename from apps/plane/layouts/DefaultLayout.tsx rename to apps/app/layouts/DefaultLayout.tsx diff --git a/apps/plane/layouts/Navbar/DefaultTopBar.tsx b/apps/app/layouts/Navbar/DefaultTopBar.tsx similarity index 100% rename from apps/plane/layouts/Navbar/DefaultTopBar.tsx rename to apps/app/layouts/Navbar/DefaultTopBar.tsx diff --git a/apps/plane/layouts/Navbar/Sidebar.tsx b/apps/app/layouts/Navbar/Sidebar.tsx similarity index 100% rename from apps/plane/layouts/Navbar/Sidebar.tsx rename to apps/app/layouts/Navbar/Sidebar.tsx diff --git a/apps/plane/layouts/types.d.ts b/apps/app/layouts/types.d.ts similarity index 100% rename from apps/plane/layouts/types.d.ts rename to apps/app/layouts/types.d.ts diff --git a/apps/plane/lib/cookie.ts b/apps/app/lib/cookie.ts similarity index 100% rename from apps/plane/lib/cookie.ts rename to apps/app/lib/cookie.ts diff --git a/apps/plane/lib/hoc/withAuthWrapper.tsx b/apps/app/lib/hoc/withAuthWrapper.tsx similarity index 100% rename from apps/plane/lib/hoc/withAuthWrapper.tsx rename to apps/app/lib/hoc/withAuthWrapper.tsx diff --git a/apps/plane/lib/hooks/useAutosizeTextArea.tsx b/apps/app/lib/hooks/useAutosizeTextArea.tsx similarity index 100% rename from apps/plane/lib/hooks/useAutosizeTextArea.tsx rename to apps/app/lib/hooks/useAutosizeTextArea.tsx diff --git a/apps/plane/lib/hooks/useIssuesProperties.tsx b/apps/app/lib/hooks/useIssuesProperties.tsx similarity index 100% rename from apps/plane/lib/hooks/useIssuesProperties.tsx rename to apps/app/lib/hooks/useIssuesProperties.tsx diff --git a/apps/plane/lib/hooks/useLocalStorage.tsx b/apps/app/lib/hooks/useLocalStorage.tsx similarity index 100% rename from apps/plane/lib/hooks/useLocalStorage.tsx rename to apps/app/lib/hooks/useLocalStorage.tsx diff --git a/apps/plane/lib/hooks/useTheme.tsx b/apps/app/lib/hooks/useTheme.tsx similarity index 100% rename from apps/plane/lib/hooks/useTheme.tsx rename to apps/app/lib/hooks/useTheme.tsx diff --git a/apps/plane/lib/hooks/useToast.tsx b/apps/app/lib/hooks/useToast.tsx similarity index 100% rename from apps/plane/lib/hooks/useToast.tsx rename to apps/app/lib/hooks/useToast.tsx diff --git a/apps/plane/lib/hooks/useUser.tsx b/apps/app/lib/hooks/useUser.tsx similarity index 100% rename from apps/plane/lib/hooks/useUser.tsx rename to apps/app/lib/hooks/useUser.tsx diff --git a/apps/plane/lib/redirect.ts b/apps/app/lib/redirect.ts similarity index 100% rename from apps/plane/lib/redirect.ts rename to apps/app/lib/redirect.ts diff --git a/apps/plane/lib/services/api.service.ts b/apps/app/lib/services/api.service.ts similarity index 100% rename from apps/plane/lib/services/api.service.ts rename to apps/app/lib/services/api.service.ts diff --git a/apps/plane/lib/services/authentication.service.ts b/apps/app/lib/services/authentication.service.ts similarity index 100% rename from apps/plane/lib/services/authentication.service.ts rename to apps/app/lib/services/authentication.service.ts diff --git a/apps/plane/lib/services/cycles.services.ts b/apps/app/lib/services/cycles.services.ts similarity index 100% rename from apps/plane/lib/services/cycles.services.ts rename to apps/app/lib/services/cycles.services.ts diff --git a/apps/plane/lib/services/file.services.ts b/apps/app/lib/services/file.services.ts similarity index 100% rename from apps/plane/lib/services/file.services.ts rename to apps/app/lib/services/file.services.ts diff --git a/apps/plane/lib/services/issues.services.ts b/apps/app/lib/services/issues.services.ts similarity index 100% rename from apps/plane/lib/services/issues.services.ts rename to apps/app/lib/services/issues.services.ts diff --git a/apps/plane/lib/services/project.service.ts b/apps/app/lib/services/project.service.ts similarity index 100% rename from apps/plane/lib/services/project.service.ts rename to apps/app/lib/services/project.service.ts diff --git a/apps/plane/lib/services/state.services.ts b/apps/app/lib/services/state.services.ts similarity index 100% rename from apps/plane/lib/services/state.services.ts rename to apps/app/lib/services/state.services.ts diff --git a/apps/plane/lib/services/user.service.ts b/apps/app/lib/services/user.service.ts similarity index 100% rename from apps/plane/lib/services/user.service.ts rename to apps/app/lib/services/user.service.ts diff --git a/apps/plane/lib/services/workspace.service.ts b/apps/app/lib/services/workspace.service.ts similarity index 100% rename from apps/plane/lib/services/workspace.service.ts rename to apps/app/lib/services/workspace.service.ts diff --git a/apps/plane/next-env.d.ts b/apps/app/next-env.d.ts similarity index 100% rename from apps/plane/next-env.d.ts rename to apps/app/next-env.d.ts diff --git a/apps/plane/next.config.js b/apps/app/next.config.js similarity index 100% rename from apps/plane/next.config.js rename to apps/app/next.config.js diff --git a/apps/plane/package.json b/apps/app/package.json similarity index 100% rename from apps/plane/package.json rename to apps/app/package.json diff --git a/apps/plane/pages/_app.tsx b/apps/app/pages/_app.tsx similarity index 100% rename from apps/plane/pages/_app.tsx rename to apps/app/pages/_app.tsx diff --git a/apps/plane/pages/api/hello.ts b/apps/app/pages/api/hello.ts similarity index 100% rename from apps/plane/pages/api/hello.ts rename to apps/app/pages/api/hello.ts diff --git a/apps/plane/pages/create-workspace.tsx b/apps/app/pages/create-workspace.tsx similarity index 100% rename from apps/plane/pages/create-workspace.tsx rename to apps/app/pages/create-workspace.tsx diff --git a/apps/plane/pages/editor.tsx b/apps/app/pages/editor.tsx similarity index 100% rename from apps/plane/pages/editor.tsx rename to apps/app/pages/editor.tsx diff --git a/apps/plane/pages/index.tsx b/apps/app/pages/index.tsx similarity index 100% rename from apps/plane/pages/index.tsx rename to apps/app/pages/index.tsx diff --git a/apps/plane/pages/invitations.tsx b/apps/app/pages/invitations.tsx similarity index 100% rename from apps/plane/pages/invitations.tsx rename to apps/app/pages/invitations.tsx diff --git a/apps/plane/pages/magic-sign-in.tsx b/apps/app/pages/magic-sign-in.tsx similarity index 100% rename from apps/plane/pages/magic-sign-in.tsx rename to apps/app/pages/magic-sign-in.tsx diff --git a/apps/plane/pages/me/my-issues.tsx b/apps/app/pages/me/my-issues.tsx similarity index 100% rename from apps/plane/pages/me/my-issues.tsx rename to apps/app/pages/me/my-issues.tsx diff --git a/apps/plane/pages/me/profile.tsx b/apps/app/pages/me/profile.tsx similarity index 100% rename from apps/plane/pages/me/profile.tsx rename to apps/app/pages/me/profile.tsx diff --git a/apps/plane/pages/me/workspace-invites.tsx b/apps/app/pages/me/workspace-invites.tsx similarity index 100% rename from apps/plane/pages/me/workspace-invites.tsx rename to apps/app/pages/me/workspace-invites.tsx diff --git a/apps/plane/pages/projects/[projectId]/cycles.tsx b/apps/app/pages/projects/[projectId]/cycles.tsx similarity index 100% rename from apps/plane/pages/projects/[projectId]/cycles.tsx rename to apps/app/pages/projects/[projectId]/cycles.tsx diff --git a/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/projects/[projectId]/issues/[issueId].tsx similarity index 100% rename from apps/plane/pages/projects/[projectId]/issues/[issueId].tsx rename to apps/app/pages/projects/[projectId]/issues/[issueId].tsx diff --git a/apps/plane/pages/projects/[projectId]/issues/index.tsx b/apps/app/pages/projects/[projectId]/issues/index.tsx similarity index 100% rename from apps/plane/pages/projects/[projectId]/issues/index.tsx rename to apps/app/pages/projects/[projectId]/issues/index.tsx diff --git a/apps/plane/pages/projects/[projectId]/members.tsx b/apps/app/pages/projects/[projectId]/members.tsx similarity index 100% rename from apps/plane/pages/projects/[projectId]/members.tsx rename to apps/app/pages/projects/[projectId]/members.tsx diff --git a/apps/plane/pages/projects/[projectId]/settings.tsx b/apps/app/pages/projects/[projectId]/settings.tsx similarity index 100% rename from apps/plane/pages/projects/[projectId]/settings.tsx rename to apps/app/pages/projects/[projectId]/settings.tsx diff --git a/apps/plane/pages/projects/index.tsx b/apps/app/pages/projects/index.tsx similarity index 100% rename from apps/plane/pages/projects/index.tsx rename to apps/app/pages/projects/index.tsx diff --git a/apps/plane/pages/signin.tsx b/apps/app/pages/signin.tsx similarity index 100% rename from apps/plane/pages/signin.tsx rename to apps/app/pages/signin.tsx diff --git a/apps/plane/pages/workspace-member-invitation/[invitationId].tsx b/apps/app/pages/workspace-member-invitation/[invitationId].tsx similarity index 100% rename from apps/plane/pages/workspace-member-invitation/[invitationId].tsx rename to apps/app/pages/workspace-member-invitation/[invitationId].tsx diff --git a/apps/plane/pages/workspace/index.tsx b/apps/app/pages/workspace/index.tsx similarity index 100% rename from apps/plane/pages/workspace/index.tsx rename to apps/app/pages/workspace/index.tsx diff --git a/apps/plane/pages/workspace/members.tsx b/apps/app/pages/workspace/members.tsx similarity index 100% rename from apps/plane/pages/workspace/members.tsx rename to apps/app/pages/workspace/members.tsx diff --git a/apps/plane/pages/workspace/settings.tsx b/apps/app/pages/workspace/settings.tsx similarity index 100% rename from apps/plane/pages/workspace/settings.tsx rename to apps/app/pages/workspace/settings.tsx diff --git a/apps/plane/postcss.config.js b/apps/app/postcss.config.js similarity index 100% rename from apps/plane/postcss.config.js rename to apps/app/postcss.config.js diff --git a/apps/plane/public/animated-icons/uploading.json b/apps/app/public/animated-icons/uploading.json similarity index 100% rename from apps/plane/public/animated-icons/uploading.json rename to apps/app/public/animated-icons/uploading.json diff --git a/apps/plane/public/favicon.ico b/apps/app/public/favicon.ico similarity index 100% rename from apps/plane/public/favicon.ico rename to apps/app/public/favicon.ico diff --git a/apps/plane/public/favicon/android-chrome-192x192.png b/apps/app/public/favicon/android-chrome-192x192.png similarity index 100% rename from apps/plane/public/favicon/android-chrome-192x192.png rename to apps/app/public/favicon/android-chrome-192x192.png diff --git a/apps/plane/public/favicon/android-chrome-512x512.png b/apps/app/public/favicon/android-chrome-512x512.png similarity index 100% rename from apps/plane/public/favicon/android-chrome-512x512.png rename to apps/app/public/favicon/android-chrome-512x512.png diff --git a/apps/plane/public/favicon/apple-touch-icon.png b/apps/app/public/favicon/apple-touch-icon.png similarity index 100% rename from apps/plane/public/favicon/apple-touch-icon.png rename to apps/app/public/favicon/apple-touch-icon.png diff --git a/apps/plane/public/favicon/favicon-16x16.png b/apps/app/public/favicon/favicon-16x16.png similarity index 100% rename from apps/plane/public/favicon/favicon-16x16.png rename to apps/app/public/favicon/favicon-16x16.png diff --git a/apps/plane/public/favicon/favicon-32x32.png b/apps/app/public/favicon/favicon-32x32.png similarity index 100% rename from apps/plane/public/favicon/favicon-32x32.png rename to apps/app/public/favicon/favicon-32x32.png diff --git a/apps/plane/public/favicon/favicon.ico b/apps/app/public/favicon/favicon.ico similarity index 100% rename from apps/plane/public/favicon/favicon.ico rename to apps/app/public/favicon/favicon.ico diff --git a/apps/plane/public/favicon/site.webmanifest b/apps/app/public/favicon/site.webmanifest similarity index 100% rename from apps/plane/public/favicon/site.webmanifest rename to apps/app/public/favicon/site.webmanifest diff --git a/apps/plane/public/logo.png b/apps/app/public/logo.png similarity index 100% rename from apps/plane/public/logo.png rename to apps/app/public/logo.png diff --git a/apps/plane/public/logos/github.png b/apps/app/public/logos/github.png similarity index 100% rename from apps/plane/public/logos/github.png rename to apps/app/public/logos/github.png diff --git a/apps/plane/public/sign-in-bg.png b/apps/app/public/sign-in-bg.png similarity index 100% rename from apps/plane/public/sign-in-bg.png rename to apps/app/public/sign-in-bg.png diff --git a/apps/plane/public/sign-up-sideimg.svg b/apps/app/public/sign-up-sideimg.svg similarity index 100% rename from apps/plane/public/sign-up-sideimg.svg rename to apps/app/public/sign-up-sideimg.svg diff --git a/apps/plane/public/site-image.png b/apps/app/public/site-image.png similarity index 100% rename from apps/plane/public/site-image.png rename to apps/app/public/site-image.png diff --git a/apps/plane/public/site.webmanifest.json b/apps/app/public/site.webmanifest.json similarity index 100% rename from apps/plane/public/site.webmanifest.json rename to apps/app/public/site.webmanifest.json diff --git a/apps/plane/public/vercel.svg b/apps/app/public/vercel.svg similarity index 100% rename from apps/plane/public/vercel.svg rename to apps/app/public/vercel.svg diff --git a/apps/plane/styles/editor.css b/apps/app/styles/editor.css similarity index 100% rename from apps/plane/styles/editor.css rename to apps/app/styles/editor.css diff --git a/apps/plane/styles/globals.css b/apps/app/styles/globals.css similarity index 100% rename from apps/plane/styles/globals.css rename to apps/app/styles/globals.css diff --git a/apps/plane/tailwind.config.js b/apps/app/tailwind.config.js similarity index 100% rename from apps/plane/tailwind.config.js rename to apps/app/tailwind.config.js diff --git a/apps/plane/tsconfig.json b/apps/app/tsconfig.json similarity index 100% rename from apps/plane/tsconfig.json rename to apps/app/tsconfig.json diff --git a/apps/plane/types/index.d.ts b/apps/app/types/index.d.ts similarity index 100% rename from apps/plane/types/index.d.ts rename to apps/app/types/index.d.ts diff --git a/apps/plane/types/invitation.d.ts b/apps/app/types/invitation.d.ts similarity index 100% rename from apps/plane/types/invitation.d.ts rename to apps/app/types/invitation.d.ts diff --git a/apps/plane/types/issues.d.ts b/apps/app/types/issues.d.ts similarity index 100% rename from apps/plane/types/issues.d.ts rename to apps/app/types/issues.d.ts diff --git a/apps/plane/types/projects.d.ts b/apps/app/types/projects.d.ts similarity index 100% rename from apps/plane/types/projects.d.ts rename to apps/app/types/projects.d.ts diff --git a/apps/plane/types/sprints.d.ts b/apps/app/types/sprints.d.ts similarity index 100% rename from apps/plane/types/sprints.d.ts rename to apps/app/types/sprints.d.ts diff --git a/apps/plane/types/state.d.ts b/apps/app/types/state.d.ts similarity index 100% rename from apps/plane/types/state.d.ts rename to apps/app/types/state.d.ts diff --git a/apps/plane/types/users.d.ts b/apps/app/types/users.d.ts similarity index 100% rename from apps/plane/types/users.d.ts rename to apps/app/types/users.d.ts diff --git a/apps/plane/types/workspace.d.ts b/apps/app/types/workspace.d.ts similarity index 100% rename from apps/plane/types/workspace.d.ts rename to apps/app/types/workspace.d.ts diff --git a/apps/plane/ui/Breadcrumbs/index.tsx b/apps/app/ui/Breadcrumbs/index.tsx similarity index 100% rename from apps/plane/ui/Breadcrumbs/index.tsx rename to apps/app/ui/Breadcrumbs/index.tsx diff --git a/apps/plane/ui/Button/index.tsx b/apps/app/ui/Button/index.tsx similarity index 100% rename from apps/plane/ui/Button/index.tsx rename to apps/app/ui/Button/index.tsx diff --git a/apps/plane/ui/CustomListbox/index.tsx b/apps/app/ui/CustomListbox/index.tsx similarity index 100% rename from apps/plane/ui/CustomListbox/index.tsx rename to apps/app/ui/CustomListbox/index.tsx diff --git a/apps/plane/ui/CustomListbox/types.d.ts b/apps/app/ui/CustomListbox/types.d.ts similarity index 100% rename from apps/plane/ui/CustomListbox/types.d.ts rename to apps/app/ui/CustomListbox/types.d.ts diff --git a/apps/plane/ui/EmptySpace/index.tsx b/apps/app/ui/EmptySpace/index.tsx similarity index 100% rename from apps/plane/ui/EmptySpace/index.tsx rename to apps/app/ui/EmptySpace/index.tsx diff --git a/apps/plane/ui/HeaderButton/index.tsx b/apps/app/ui/HeaderButton/index.tsx similarity index 100% rename from apps/plane/ui/HeaderButton/index.tsx rename to apps/app/ui/HeaderButton/index.tsx diff --git a/apps/plane/ui/Input/index.tsx b/apps/app/ui/Input/index.tsx similarity index 100% rename from apps/plane/ui/Input/index.tsx rename to apps/app/ui/Input/index.tsx diff --git a/apps/plane/ui/Input/types.d.ts b/apps/app/ui/Input/types.d.ts similarity index 100% rename from apps/plane/ui/Input/types.d.ts rename to apps/app/ui/Input/types.d.ts diff --git a/apps/plane/ui/Modal/index.tsx b/apps/app/ui/Modal/index.tsx similarity index 100% rename from apps/plane/ui/Modal/index.tsx rename to apps/app/ui/Modal/index.tsx diff --git a/apps/plane/ui/SearchListbox/index.tsx b/apps/app/ui/SearchListbox/index.tsx similarity index 100% rename from apps/plane/ui/SearchListbox/index.tsx rename to apps/app/ui/SearchListbox/index.tsx diff --git a/apps/plane/ui/SearchListbox/types.d.ts b/apps/app/ui/SearchListbox/types.d.ts similarity index 100% rename from apps/plane/ui/SearchListbox/types.d.ts rename to apps/app/ui/SearchListbox/types.d.ts diff --git a/apps/plane/ui/Select/index.tsx b/apps/app/ui/Select/index.tsx similarity index 100% rename from apps/plane/ui/Select/index.tsx rename to apps/app/ui/Select/index.tsx diff --git a/apps/plane/ui/Select/types.d.ts b/apps/app/ui/Select/types.d.ts similarity index 100% rename from apps/plane/ui/Select/types.d.ts rename to apps/app/ui/Select/types.d.ts diff --git a/apps/plane/ui/Spinner/index.tsx b/apps/app/ui/Spinner/index.tsx similarity index 100% rename from apps/plane/ui/Spinner/index.tsx rename to apps/app/ui/Spinner/index.tsx diff --git a/apps/plane/ui/TextArea/index.tsx b/apps/app/ui/TextArea/index.tsx similarity index 100% rename from apps/plane/ui/TextArea/index.tsx rename to apps/app/ui/TextArea/index.tsx diff --git a/apps/plane/ui/TextArea/types.d.ts b/apps/app/ui/TextArea/types.d.ts similarity index 100% rename from apps/plane/ui/TextArea/types.d.ts rename to apps/app/ui/TextArea/types.d.ts diff --git a/apps/plane/ui/Tooltip/index.tsx b/apps/app/ui/Tooltip/index.tsx similarity index 100% rename from apps/plane/ui/Tooltip/index.tsx rename to apps/app/ui/Tooltip/index.tsx diff --git a/apps/plane/ui/index.ts b/apps/app/ui/index.ts similarity index 100% rename from apps/plane/ui/index.ts rename to apps/app/ui/index.ts diff --git a/apps/plane/yarn.lock b/apps/app/yarn.lock similarity index 100% rename from apps/plane/yarn.lock rename to apps/app/yarn.lock diff --git a/apps/plane/Dockerfile b/apps/plane/Dockerfile deleted file mode 100644 index 978b60ef4..000000000 --- a/apps/plane/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -FROM node:alpine AS builder -RUN apk add --no-cache libc6-compat -RUN apk update -# Set working directory -WORKDIR /app -RUN yarn global add turbo -COPY ./apps ./apps -COPY ./package.json ./package.json -COPY ./.eslintrc.json ./.eslintrc.json -COPY ./turbo.json ./turbo.json -COPY ./yarn.lock ./yarn.lock -RUN turbo prune --scope=plane --docker - -# Add lockfile and package.json's of isolated subworkspace -FROM node:alpine AS installer -RUN apk add --no-cache libc6-compat -RUN apk update -WORKDIR /app - -# First install the dependencies (as they change less often) -COPY .gitignore .gitignore -COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install - -# Build the project -COPY --from=builder /app/out/full/ . -COPY turbo.json turbo.json -RUN yarn turbo run build --filter=plane... - -FROM node:alpine AS runner -WORKDIR /app - -# Don't run production as root -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -USER nextjs - -COPY --from=installer /app/apps/plane/next.config.js . -COPY --from=installer /app/apps/plane/package.json . - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=installer --chown=nextjs:nodejs /app/apps/plane/.next/standalone ./ -COPY --from=installer --chown=nextjs:nodejs /app/apps/plane/.next/static ./apps/plane/.next/static - -CMD node apps/plane/server.js \ No newline at end of file diff --git a/apiserver/bin/takeoff b/bin/takeoff similarity index 100% rename from apiserver/bin/takeoff rename to bin/takeoff diff --git a/docker-compose.yml b/docker-compose.yml index d4267c4f3..9b85b0481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: container_name: plane_web build: context: . - dockerfile: ./apps/plane/Dockerfile + dockerfile: ./apps/app/Dockerfile restart: always ports: - 3000:3000 From 814982a9be95dafde8b9c5f20914e70fa7ef31d1 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 2 Dec 2022 23:39:03 +0530 Subject: [PATCH 005/104] build: finalize dockerfiles update package.json to include all lexical dependencies --- apiserver/Dockerfile | 5 ++- {bin => apiserver/bin}/takeoff | 0 apiserver/plane/settings/production.py | 2 +- apps/app/Dockerfile | 2 +- apps/app/package.json | 7 +++ docker-compose.yml | 11 +++++ pnpm-lock.yaml | 60 ++++++++++++++++++++++---- 7 files changed, 76 insertions(+), 11 deletions(-) rename {bin => apiserver/bin}/takeoff (100%) diff --git a/apiserver/Dockerfile b/apiserver/Dockerfile index 967e34c5f..f2aa59e51 100644 --- a/apiserver/Dockerfile +++ b/apiserver/Dockerfile @@ -3,6 +3,7 @@ FROM python:3.8.14-alpine3.16 AS backend # set environment variables ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /code @@ -12,9 +13,9 @@ RUN apk --update --no-cache add \ "nodejs-current~=18" \ "xmlsec~=1.2" - COPY requirements.txt ./ COPY requirements ./requirements +RUN apk add libffi-dev RUN apk --update --no-cache --virtual .build-deps add \ "bash~=5.1" \ "g++~=11.2" \ @@ -23,6 +24,8 @@ RUN apk --update --no-cache --virtual .build-deps add \ "git~=2" \ "make~=4.3" \ "postgresql13-dev~=13" \ + "libc-dev" \ + "linux-headers" \ && \ pip install -r requirements.txt --compile --no-cache-dir \ && \ diff --git a/bin/takeoff b/apiserver/bin/takeoff similarity index 100% rename from bin/takeoff rename to apiserver/bin/takeoff diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index ab84c36d5..b7312fb83 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -174,7 +174,7 @@ CACHES = { "LOCATION": REDIS_URL, "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + # "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, }, } } diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile index 967096ba3..f567f5501 100644 --- a/apps/app/Dockerfile +++ b/apps/app/Dockerfile @@ -15,7 +15,6 @@ COPY ./apps ./apps COPY ./package.json ./package.json COPY ./.eslintrc.json ./.eslintrc.json COPY ./turbo.json ./turbo.json -COPY ./yarn.lock ./yarn.lock COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml COPY ./pnpm-lock.yaml ./pnpm-lock.yaml @@ -45,6 +44,7 @@ RUN pnpm install # Build the project COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json +# RUN pnpm add -g turbo RUN pnpm turbo run build --filter=app... FROM node:alpine AS runner diff --git a/apps/app/package.json b/apps/app/package.json index f2c5186db..ac979ef0f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,6 +14,13 @@ "@lexical/list": "^0.6.4", "@lexical/react": "^0.6.4", "@lexical/utils": "^0.6.4", + "@lexical/rich-text": "^0.6.4", + "@lexical/code": "^0.6.4", + "@lexical/table": "^0.6.4", + "@lexical/link": "^0.6.4", + "@lexical/markdown": "^0.6.4", + "@lexical/html": "^0.6.4", + "@lexical/selection": "^0.6.4", "axios": "^1.1.3", "js-cookie": "^3.0.1", "lexical": "^0.6.4", diff --git a/docker-compose.yml b/docker-compose.yml index 9b85b0481..83e8bc891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,10 @@ services: context: . dockerfile: ./apps/app/Dockerfile restart: always + environment: + NEXT_PUBLIC_GITHUB_ID: $NEXT_PUBLIC_GITHUB_ID + NEXT_PUBLIC_GOOGLE_CLIENTID: $NEXT_PUBLIC_GOOGLE_CLIENTID + NEXT_PUBLIC_API_BASE_URL: $NEXT_PUBLIC_API_BASE_URL ports: - 3000:3000 networks: @@ -48,6 +52,13 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 SECRET_KEY: $SECRET_KEY + AWS_REGION: $AWS_REGION + AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY + AWS_S3_BUCKET_NAME: $AWS_S3_BUCKET_NAME + EMAIL_HOST: $EMAIL_HOST + EMAIL_HOST_USER: $EMAIL_HOST_USER + EMAIL_HOST_PASSWORD: $EMAIL_HOST_PASSWORD networks: - app_network depends_on: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fafc9a74..6779c9081 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,15 +9,22 @@ importers: turbo: latest devDependencies: eslint: 8.28.0 - eslint-config-turbo: 0.0.4_eslint@8.28.0 + eslint-config-turbo: 0.0.7_eslint@8.28.0 turbo: 1.6.3 apps/app: specifiers: '@headlessui/react': ^1.7.3 '@heroicons/react': ^2.0.12 + '@lexical/code': ^0.6.4 + '@lexical/html': ^0.6.4 + '@lexical/link': ^0.6.4 '@lexical/list': ^0.6.4 + '@lexical/markdown': ^0.6.4 '@lexical/react': ^0.6.4 + '@lexical/rich-text': ^0.6.4 + '@lexical/selection': ^0.6.4 + '@lexical/table': ^0.6.4 '@lexical/utils': ^0.6.4 '@types/js-cookie': ^3.0.2 '@types/lodash': ^4.14.188 @@ -54,8 +61,15 @@ importers: dependencies: '@headlessui/react': 1.7.4_biqbaboplfbrettd7655fr4n2y '@heroicons/react': 2.0.13_react@18.2.0 + '@lexical/code': 0.6.4_lexical@0.6.4 + '@lexical/html': 0.6.4_lexical@0.6.4 + '@lexical/link': 0.6.4_lexical@0.6.4 '@lexical/list': 0.6.4_lexical@0.6.4 + '@lexical/markdown': 0.6.4_bxsdi55v3sssedk76lbl4oc7vi '@lexical/react': 0.6.4_kpr2eqve2cw3wrreahgz2ip7ja + '@lexical/rich-text': 0.6.4_57tb3ip3heyjq7opzdlcjoitia + '@lexical/selection': 0.6.4_lexical@0.6.4 + '@lexical/table': 0.6.4_lexical@0.6.4 '@lexical/utils': 0.6.4_lexical@0.6.4 axios: 1.2.0 js-cookie: 3.0.1 @@ -267,6 +281,23 @@ packages: lexical: 0.6.4 dev: false + /@lexical/markdown/0.6.4_bxsdi55v3sssedk76lbl4oc7vi: + resolution: {integrity: sha512-9kg+BsP4ePCztrK7UYW8a+8ad1/h/OLziJkMZkl3YAkfhJudkHoj4ljCTJZcLuXHtVXmvLZhyGZktitcJImnOg==} + peerDependencies: + lexical: 0.6.4 + dependencies: + '@lexical/code': 0.6.4_lexical@0.6.4 + '@lexical/link': 0.6.4_lexical@0.6.4 + '@lexical/list': 0.6.4_lexical@0.6.4 + '@lexical/rich-text': 0.6.4_57tb3ip3heyjq7opzdlcjoitia + '@lexical/text': 0.6.4_lexical@0.6.4 + '@lexical/utils': 0.6.4_lexical@0.6.4 + lexical: 0.6.4 + transitivePeerDependencies: + - '@lexical/clipboard' + - '@lexical/selection' + dev: false + /@lexical/markdown/0.6.4_dlxdgdmlaurtixklkqcbpqk7be: resolution: {integrity: sha512-9kg+BsP4ePCztrK7UYW8a+8ad1/h/OLziJkMZkl3YAkfhJudkHoj4ljCTJZcLuXHtVXmvLZhyGZktitcJImnOg==} peerDependencies: @@ -346,6 +377,19 @@ packages: - yjs dev: false + /@lexical/rich-text/0.6.4_57tb3ip3heyjq7opzdlcjoitia: + resolution: {integrity: sha512-GUTAEUPmSKzL1kldvdHqM9IgiAJC1qfMeDQFyUS2xwWKQnid0nVeUZXNxyBwxZLyOcyDkx5dXp9YiEO6X4x+TQ==} + peerDependencies: + '@lexical/clipboard': 0.6.4 + '@lexical/selection': 0.6.4 + '@lexical/utils': 0.6.4 + lexical: 0.6.4 + dependencies: + '@lexical/selection': 0.6.4_lexical@0.6.4 + '@lexical/utils': 0.6.4_lexical@0.6.4 + lexical: 0.6.4 + dev: false + /@lexical/rich-text/0.6.4_jxqtqauh5scnexlu66czmxsxkq: resolution: {integrity: sha512-GUTAEUPmSKzL1kldvdHqM9IgiAJC1qfMeDQFyUS2xwWKQnid0nVeUZXNxyBwxZLyOcyDkx5dXp9YiEO6X4x+TQ==} peerDependencies: @@ -1193,13 +1237,13 @@ packages: - supports-color dev: true - /eslint-config-turbo/0.0.4_eslint@8.28.0: - resolution: {integrity: sha512-HErPS/wfWkSdV9Yd2dDkhZt3W2B78Ih/aWPFfaHmCMjzPalh+5KxRRGTf8MOBQLCebcWJX0lP1Zvc1rZIHlXGg==} + /eslint-config-turbo/0.0.7_eslint@8.28.0: + resolution: {integrity: sha512-WbrGlyfs94rOXrhombi1wjIAYGdV2iosgJRndOZtmDQeq5GLTzYmBUCJQZWtLBEBUPCj96RxZ2OL7Cn+xv/Azg==} peerDependencies: - eslint: ^7.23.0 || ^8.0.0 + eslint: '>6.6.0' dependencies: eslint: 8.28.0 - eslint-plugin-turbo: 0.0.4_eslint@8.28.0 + eslint-plugin-turbo: 0.0.7_eslint@8.28.0 dev: true /eslint-import-resolver-node/0.3.6: @@ -1345,10 +1389,10 @@ packages: string.prototype.matchall: 4.0.8 dev: true - /eslint-plugin-turbo/0.0.4_eslint@8.28.0: - resolution: {integrity: sha512-dfmYE/iPvoJInQq+5E/0mj140y/rYwKtzZkn3uVK8+nvwC5zmWKQ6ehMWrL4bYBkGzSgpOndZM+jOXhPQ2m8Cg==} + /eslint-plugin-turbo/0.0.7_eslint@8.28.0: + resolution: {integrity: sha512-iajOH8eD4jha3duztGVBD1BEmvNrQBaA/y3HFHf91vMDRYRwH7BpHSDFtxydDpk5ghlhRxG299SFxz7D6z4MBQ==} peerDependencies: - eslint: ^7.23.0 || ^8.0.0 + eslint: '>6.6.0' dependencies: eslint: 8.28.0 dev: true From 46b7ec71e34de1c1840e80b833d151d94620cc9f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sun, 4 Dec 2022 22:06:59 +0530 Subject: [PATCH 006/104] build: update docker file names to push it to heroku --- apiserver/{Dockerfile => Dockerfile.api} | 0 apps/app/{Dockerfile => Dockerfile.web} | 0 docker-compose.yml | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename apiserver/{Dockerfile => Dockerfile.api} (100%) rename apps/app/{Dockerfile => Dockerfile.web} (100%) diff --git a/apiserver/Dockerfile b/apiserver/Dockerfile.api similarity index 100% rename from apiserver/Dockerfile rename to apiserver/Dockerfile.api diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile.web similarity index 100% rename from apps/app/Dockerfile rename to apps/app/Dockerfile.web diff --git a/docker-compose.yml b/docker-compose.yml index 83e8bc891..ef48685b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3" +version: "3.8" services: db: @@ -23,7 +23,7 @@ services: container_name: plane_web build: context: . - dockerfile: ./apps/app/Dockerfile + dockerfile: ./apps/app/Dockerfile.web restart: always environment: NEXT_PUBLIC_GITHUB_ID: $NEXT_PUBLIC_GITHUB_ID @@ -38,7 +38,7 @@ services: container_name: plane_api build: context: ./apiserver - dockerfile: Dockerfile + dockerfile: Dockerfile.api restart: always ports: - 8000:8000 From 74d66cec6a397481d77feb139b8c61ea099663c1 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sun, 4 Dec 2022 23:26:58 +0530 Subject: [PATCH 007/104] build: nginx configurations --- docker-compose.yml | 26 ++++++++++------- nginx/Dockerfile | 4 +++ nginx/dev.conf | 27 +++++++++++++++++ nginx/nginx.conf | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 nginx/Dockerfile create mode 100644 nginx/dev.conf create mode 100644 nginx/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index ef48685b5..614f5e2a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,14 +11,12 @@ services: POSTGRES_DB: plane POSTGRES_PASSWORD: plane command: postgres -c 'max_connections=1000' - networks: - - app_network + redis: image: redis:6.2.7-alpine restart: on-failure command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb - networks: - - app_network + plane_web: container_name: plane_web build: @@ -31,8 +29,7 @@ services: NEXT_PUBLIC_API_BASE_URL: $NEXT_PUBLIC_API_BASE_URL ports: - 3000:3000 - networks: - - app_network + plane_api: container_name: plane_api @@ -59,8 +56,7 @@ services: EMAIL_HOST: $EMAIL_HOST EMAIL_HOST_USER: $EMAIL_HOST_USER EMAIL_HOST_PASSWORD: $EMAIL_HOST_PASSWORD - networks: - - app_network + depends_on: - db - redis @@ -68,9 +64,17 @@ services: links: - db - redis -networks: - app_network: - external: true + + nginx: + build: + context: ./nginx + dockerfile: Dockerfile + restart: unless-stopped + ports: + - 80:80 + depends_on: + - plane_api + - plane_web volumes: postgres-data: diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 000000000..529dff404 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.23.2-alpine + +RUN rm /etc/nginx/conf.d/default.conf +COPY /dev.conf /etc/nginx/conf.d diff --git a/nginx/dev.conf b/nginx/dev.conf new file mode 100644 index 000000000..3931dee40 --- /dev/null +++ b/nginx/dev.conf @@ -0,0 +1,27 @@ +server { + listen 80; + + location / { + proxy_pass http://plane_web:3000; + proxy_redirect default; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api { + proxy_pass http://plane_api:8000; + proxy_redirect default; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 000000000..9981af6f8 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,72 @@ +## Version 2018/04/07 - Changelog: https://github.com/linuxserver/docker-letsencrypt/commits/master/root/defaults/nginx.conf + +user abc; +worker_processes 4; +pid /run/nginx.pid; +include /etc/nginx/modules/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + variables_hash_max_size 2048; + + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + client_max_body_size 0; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # Logging Settings + ## + + access_log /config/log/nginx/access.log; + error_log /config/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 32 16k; + gzip_http_version 1.1; + gzip_min_length 250; + gzip_types image/jpeg image/bmp image/svg+xml text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript image/x-icon; + + # security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; + ssl_prefer_server_ciphers on; + + include /etc/nginx/conf.d/*.conf; + include /config/nginx/site-confs/*; +} + + +daemon off; From 7f91ad9285a7f8633871f0f9469ee50f0c48ac32 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sun, 4 Dec 2022 23:45:41 +0530 Subject: [PATCH 008/104] build: create heroku.yml file --- heroku.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 heroku.yml diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 000000000..4eeaf138f --- /dev/null +++ b/heroku.yml @@ -0,0 +1,19 @@ +setup: + addons: + - plan: heroku-postgresql + as: DATABASE + - plan: heroku-redis + as: REDIS + config: + env_file: .env +build: + docker: + plane_web: ./apps/app/Dockerfile.web + plane_api: ./apiserver/Dockerfile.api + +release: + plane_api: python manage.py migrate + +run: + plane_web: node apps/app/server.js + plane_api: ./bin/takeoff \ No newline at end of file From 7948f3e1cb6d7320de5b00bc45bed086603b90f2 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 5 Dec 2022 11:51:06 +0530 Subject: [PATCH 009/104] build: update plane_api run path --- heroku.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/heroku.yml b/heroku.yml index 4eeaf138f..5cf347517 100644 --- a/heroku.yml +++ b/heroku.yml @@ -16,4 +16,4 @@ release: run: plane_web: node apps/app/server.js - plane_api: ./bin/takeoff \ No newline at end of file + plane_api: ./apiserver/bin/takeoff From 1337e02e637fe9fc6e985873ed8e8873c5c6a8f8 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 8 Dec 2022 23:42:11 +0530 Subject: [PATCH 010/104] style: cycles, list view, kanban card --- .../components/project/cycles/CycleView.tsx | 146 ++-- .../project/issues/BoardView/SingleBoard.tsx | 35 +- .../project/issues/ListView/index.tsx | 791 ++++++++++-------- .../app/pages/projects/[projectId]/cycles.tsx | 2 +- apps/app/public/user.png | Bin 0 -> 99267 bytes 5 files changed, 562 insertions(+), 412 deletions(-) create mode 100644 apps/app/public/user.png diff --git a/apps/app/components/project/cycles/CycleView.tsx b/apps/app/components/project/cycles/CycleView.tsx index f46079165..b88883ae0 100644 --- a/apps/app/components/project/cycles/CycleView.tsx +++ b/apps/app/components/project/cycles/CycleView.tsx @@ -21,10 +21,11 @@ import type { CycleViewProps as Props, CycleIssueResponse, IssueResponse } from // fetch keys import { CYCLE_ISSUES } from "constants/fetch-keys"; // constants -import { renderShortNumericDateFormat } from "constants/common"; +import { addSpaceIfCamelCase, renderShortNumericDateFormat } from "constants/common"; import issuesServices from "lib/services/issues.services"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { Draggable } from "react-beautiful-dnd"; +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; const CycleView: React.FC = ({ cycle, @@ -70,14 +71,13 @@ const CycleView: React.FC = ({ /> {({ open }) => ( -
-
- +
+
+

{cycle.name}

@@ -93,11 +93,15 @@ const CycleView: React.FC = ({ {cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""}

+

{cycleIssues?.length}

- + @@ -134,7 +138,11 @@ const CycleView: React.FC = ({ {(provided) => ( -
+
{cycleIssues ? ( cycleIssues.length > 0 ? ( cycleIssues.map((issue, index) => ( @@ -145,7 +153,7 @@ const CycleView: React.FC = ({ > {(provided, snapshot) => (
= ({
- - {issue.issue_details.state_detail?.name} - +
+ + {issue.issue_details.start_date + ? renderShortNumericDateFormat( + issue.issue_details.start_date + ) + : "N/A"} +
+
+ + {addSpaceIfCamelCase(issue.issue_details.state_detail.name)} +
@@ -261,49 +277,51 @@ const CycleView: React.FC = ({ - - - - Add issue - +
+ + + + Add issue + - - -
- - {(active) => ( - - )} - - - {(active) => ( - - )} - -
-
-
-
+ + +
+ + {(active) => ( + + )} + + + {(active) => ( + + )} + +
+
+
+
+
)} diff --git a/apps/app/components/project/issues/BoardView/SingleBoard.tsx b/apps/app/components/project/issues/BoardView/SingleBoard.tsx index 2c0426e93..08efcdac3 100644 --- a/apps/app/components/project/issues/BoardView/SingleBoard.tsx +++ b/apps/app/components/project/issues/BoardView/SingleBoard.tsx @@ -1,15 +1,10 @@ import React, { useState } from "react"; // Next imports import Link from "next/link"; +import Image from "next/image"; // React beautiful dnd import { Draggable } from "react-beautiful-dnd"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; -// common -import { - addSpaceIfCamelCase, - findHowManyDaysLeft, - renderShortNumericDateFormat, -} from "constants/common"; // types import { IIssue, Properties, NestedKeyOf } from "types"; // icons @@ -20,7 +15,13 @@ import { EllipsisHorizontalIcon, PlusIcon, } from "@heroicons/react/24/outline"; -import Image from "next/image"; +import User from "public/user.png"; +// common +import { + addSpaceIfCamelCase, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; import { getPriorityIcon } from "constants/global"; type Props = { @@ -193,17 +194,19 @@ const SingleBoard: React.FC = ({ {properties.priority && (
{/* {getPriorityIcon(childIssue.priority ?? "")} */} - {childIssue.priority} + {childIssue.priority ?? "None"}
)} {properties.state && ( @@ -285,7 +288,15 @@ const SingleBoard: React.FC = ({ ) ) ) : ( - No assignee. +
+ No user +
)}
)} diff --git a/apps/app/components/project/issues/ListView/index.tsx b/apps/app/components/project/issues/ListView/index.tsx index c4a7e19f1..c12495849 100644 --- a/apps/app/components/project/issues/ListView/index.tsx +++ b/apps/app/components/project/issues/ListView/index.tsx @@ -5,10 +5,20 @@ import Link from "next/link"; import Image from "next/image"; // swr import useSWR, { mutate } from "swr"; +// headless ui +import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react"; // ui -import { Listbox, Transition } from "@headlessui/react"; +import { Spinner } from "ui"; // icons -import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { + ChevronDownIcon, + PlusIcon, + CalendarDaysIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/24/outline"; +import User from "public/user.png"; +// components +import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; // types import { IIssue, IssueResponse, NestedKeyOf, Properties, WorkspaceMember } from "types"; // hooks @@ -20,7 +30,12 @@ import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import issuesServices from "lib/services/issues.services"; import workspaceService from "lib/services/workspace.service"; // constants -import { addSpaceIfCamelCase, classNames, renderShortNumericDateFormat } from "constants/common"; +import { + addSpaceIfCamelCase, + classNames, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; // types type Props = { @@ -38,6 +53,11 @@ const ListView: React.FC = ({ setSelectedIssue, handleDeleteIssue, }) => { + const [isCreateIssuesModalOpen, setIsCreateIssuesModalOpen] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + const { activeWorkspace, activeProject, states } = useUser(); const partialUpdateIssue = (formData: Partial, issueId: string) => { @@ -66,348 +86,449 @@ const ListView: React.FC = ({ ); return ( -
- {Object.keys(groupedByIssues).map((singleGroup) => ( -
-
-
- - {selectedGroup !== null ? ( - - - - - - ) : ( - - - - - - )} - - {groupedByIssues[singleGroup].length > 0 - ? groupedByIssues[singleGroup].map((issue: IIssue, index: number) => { - const assignees = [ - ...(issue?.assignees_list ?? []), - ...(issue?.assignees ?? []), - ]?.map( - (assignee) => people?.find((p) => p.member.id === assignee)?.member.email - ); + + ) : ( +

All Issues

+ )} +

+ {groupedByIssues[singleGroup as keyof IIssue].length} +

+ + + + + +
+ {groupedByIssues[singleGroup] ? ( + groupedByIssues[singleGroup].length > 0 ? ( + groupedByIssues[singleGroup].map((issue: IIssue) => { + const assignees = [ + ...(issue?.assignees_list ?? []), + ...(issue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find( + (p) => p.member.id === assignee + )?.member; - return ( -
- - {Object.keys(properties).map( - (key) => - properties[key as keyof Properties] && ( - - {(key as keyof Properties) === "key" ? ( - - ) : (key as keyof Properties) === "priority" ? ( - - ) : (key as keyof Properties) === "assignee" ? ( - - ) : (key as keyof Properties) === "state" ? ( - - ) : (key as keyof Properties) === "target_date" ? ( - - ) : ( - - )} - - ) - )} - - - ); - }) - : null} - -
-
- {selectedGroup === "state_detail.name" ? ( - s.name === singleGroup)?.color, - }} - > - ) : null} + <> + +
+ {Object.keys(groupedByIssues).map((singleGroup) => ( + + {({ open }) => ( +
+
+ +
+ + + + {selectedGroup !== null ? ( +

{singleGroup === null || singleGroup === "null" ? selectedGroup === "priority" && "No priority" : addSpaceIfCamelCase(singleGroup)} - - {groupedByIssues[singleGroup as keyof IIssue].length} - -

-
- ALL ISSUES - - {groupedByIssues[singleGroup as keyof IIssue].length} - -
- - {issue.name} - - - {activeProject?.identifier}-{issue.sequence_id} - - { - partialUpdateIssue({ priority: data }, issue.id); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
- - + +
+ {properties.priority && ( + { + partialUpdateIssue({ priority: data }, issue.id); + }} + className="flex-shrink-0" + > + {({ open }) => ( + <> +
+ + {issue.priority ?? "None"} + + + + + {PRIORITIES?.map((priority) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer capitalize select-none px-3 py-2" + ) + } + value={priority} > - {issue.priority ?? "None"} - - + {priority} + + ))} + + +
+ + )} +
+ )} + {properties.state && ( + { + partialUpdateIssue({ state: data }, issue.id); + }} + className="flex-shrink-0" + > + {({ open }) => ( + <> +
+ + + {addSpaceIfCamelCase(issue.state_detail.name)} + - - - {PRIORITIES?.map((priority) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer capitalize select-none px-3 py-2" - ) - } - value={priority} - > - {priority} - - ))} - - -
- - )} -
-
- { - const newData = issue.assignees ?? []; - if (newData.includes(data)) { - newData.splice(newData.indexOf(data), 1); - } else { - newData.push(data); - } - partialUpdateIssue( - { assignees_list: newData }, - issue.id - ); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
- - {() => { - if (assignees.length > 0) - return ( - <> - {assignees.map((assignee, index) => ( -
- {assignee} -
- ))} - - ); - else return None; - }} -
- - - - {people?.map((person) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none px-3 py-2" - ) - } - value={person.member.id} - > -
- {person.member.avatar && - person.member.avatar !== "" ? ( -
- avatar -
- ) : ( -

- {person.member.first_name.charAt(0)} -

- )} -

{person.member.first_name}

+ + + {states?.map((state) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none px-3 py-2" + ) + } + value={state.id} + > + {addSpaceIfCamelCase(state.name)} + + ))} + + +
+ + )} + + )} + {properties.start_date && ( +
+ + {issue.start_date + ? renderShortNumericDateFormat(issue.start_date) + : "N/A"} +
+ )} + {properties.target_date && ( +
+ + {issue.target_date + ? renderShortNumericDateFormat(issue.target_date) + : "N/A"} + {issue.target_date && ( + + {issue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + issue.target_date + )} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + issue.target_date + )} days` + : "Target date"} + + )} +
+ )} + {properties.assignee && ( + { + const newData = issue.assignees ?? []; + if (newData.includes(data)) { + newData.splice(newData.indexOf(data), 1); + } else { + newData.push(data); + } + partialUpdateIssue({ assignees_list: newData }, issue.id); + }} + className="relative flex-shrink-0" + > + {({ open }) => ( + <> +
+ +
+ {assignees.length > 0 ? ( + assignees.map((assignee, index: number) => ( +
+ {assignee.avatar && assignee.avatar !== "" ? ( +
+ {assignee?.first_name}
- - ))} - - + ) : ( +
+ {assignee.first_name?.charAt(0)} +
+ )} +
+ )) + ) : ( +
+ No user +
+ )}
- - )} - -
- { - partialUpdateIssue({ state: data }, issue.id); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
- - - {addSpaceIfCamelCase(issue.state_detail.name)} - - + - - - {states?.map((state) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none px-3 py-2" - ) - } - value={state.id} - > - {addSpaceIfCamelCase(state.name)} - - ))} - - -
- - )} -
-
- {issue.target_date - ? renderShortNumericDateFormat(issue.target_date) - : "-"} - - {issue[key as keyof IIssue] ?? - (issue[key as keyof IIssue] as any)?.name ?? - "None"} - -
- - + + + {people?.map((person) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none px-3 py-2" + ) + } + value={person.member.id} + > +
+ {person.member.avatar && + person.member.avatar !== "" ? ( +
+ avatar +
+ ) : ( +
+ {person.member.first_name && + person.member.first_name !== "" + ? person.member.first_name.charAt(0) + : person.member.email.charAt(0)} +
+ )} +

+ {person.member.first_name && + person.member.first_name !== "" + ? person.member.first_name + : person.member.email} +

+
+
+ ))} +
+
+
+ + )} + + )} + + + + + + + + + +
+ +
+
+
+
+ -
-
-
-
- ))} -
+ ); + }) + ) : ( +

No issues.

+ ) + ) : ( +
+ +
+ )} +
+
+ +
+ +
+
+ )} + + ))} +
+ ); }; diff --git a/apps/app/pages/projects/[projectId]/cycles.tsx b/apps/app/pages/projects/[projectId]/cycles.tsx index 9afacd9ff..bde054440 100644 --- a/apps/app/pages/projects/[projectId]/cycles.tsx +++ b/apps/app/pages/projects/[projectId]/cycles.tsx @@ -203,7 +203,7 @@ const ProjectSprints: NextPage = () => { /> {cycles ? ( cycles.length > 0 ? ( -
+
diff --git a/apps/app/public/user.png b/apps/app/public/user.png new file mode 100644 index 0000000000000000000000000000000000000000..bc02512287324c4ad55c94873a716aa5e839ff14 GIT binary patch literal 99267 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 QA;Vd0xlC|-q42=}1DTO#&;S4c literal 0 HcmV?d00001 From 9afe196024cb06d9cffe98993ab99e9fb644b4ea Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 9 Dec 2022 21:09:54 +0530 Subject: [PATCH 011/104] build: move worker to different process --- apiserver/bin/takeoff | 1 - docker-compose.yml | 43 ++++++++++++++++++-------- heroku.yml | 11 ++++--- nginx/Dockerfile | 4 --- nginx/dev.conf | 27 ---------------- nginx/nginx.conf | 72 ------------------------------------------- 6 files changed, 36 insertions(+), 122 deletions(-) delete mode 100644 nginx/Dockerfile delete mode 100644 nginx/dev.conf delete mode 100644 nginx/nginx.conf diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 3ec0d34ac..d6106003e 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -2,5 +2,4 @@ set -e python manage.py migrate -python manage.py rqworker & exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - diff --git a/docker-compose.yml b/docker-compose.yml index 614f5e2a5..2963907be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,9 @@ services: restart: on-failure command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb - plane_web: - container_name: plane_web + plane-web: + image: plane-web + container_name: plane-frontend build: context: . dockerfile: ./apps/app/Dockerfile.web @@ -31,8 +32,9 @@ services: - 3000:3000 - plane_api: - container_name: plane_api + plane-api: + image: plane-api + container_name: plane-backend build: context: ./apiserver dockerfile: Dockerfile.api @@ -65,16 +67,31 @@ services: - db - redis - nginx: - build: - context: ./nginx - dockerfile: Dockerfile - restart: unless-stopped - ports: - - 80:80 + plane-worker: + image: plane-api depends_on: - - plane_api - - plane_web + - redis + command: python manage.py rqworker + links: + - redis + environment: + SENTRY_DSN: $SENTRY_DSN + WEB_URL: $WEB_URL + PGUSER: plane + PGPASSWORD: plane + PGHOST: db + REDIS_URL: 'redis://redis:6379/' + REDIS_HOST: redis + REDIS_PORT: 6379 + SECRET_KEY: $SECRET_KEY + AWS_REGION: $AWS_REGION + AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY + AWS_S3_BUCKET_NAME: $AWS_S3_BUCKET_NAME + EMAIL_HOST: $EMAIL_HOST + EMAIL_HOST_USER: $EMAIL_HOST_USER + EMAIL_HOST_PASSWORD: $EMAIL_HOST_PASSWORD + volumes: postgres-data: diff --git a/heroku.yml b/heroku.yml index 5cf347517..f308cbe08 100644 --- a/heroku.yml +++ b/heroku.yml @@ -8,12 +8,13 @@ setup: env_file: .env build: docker: - plane_web: ./apps/app/Dockerfile.web - plane_api: ./apiserver/Dockerfile.api + plane-frontend: ./apps/app/Dockerfile.web + plane-backend: ./apiserver/Dockerfile.api release: - plane_api: python manage.py migrate + plane-backend: python manage.py migrate run: - plane_web: node apps/app/server.js - plane_api: ./apiserver/bin/takeoff + plane-frontend: node apps/app/server.js + plane-backend: ./apiserver/bin/takeoff + plane-worker: python manage.py rqworker \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index 529dff404..000000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM nginx:1.23.2-alpine - -RUN rm /etc/nginx/conf.d/default.conf -COPY /dev.conf /etc/nginx/conf.d diff --git a/nginx/dev.conf b/nginx/dev.conf deleted file mode 100644 index 3931dee40..000000000 --- a/nginx/dev.conf +++ /dev/null @@ -1,27 +0,0 @@ -server { - listen 80; - - location / { - proxy_pass http://plane_web:3000; - proxy_redirect default; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /api { - proxy_pass http://plane_api:8000; - proxy_redirect default; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 9981af6f8..000000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,72 +0,0 @@ -## Version 2018/04/07 - Changelog: https://github.com/linuxserver/docker-letsencrypt/commits/master/root/defaults/nginx.conf - -user abc; -worker_processes 4; -pid /run/nginx.pid; -include /etc/nginx/modules/*.conf; - -events { - worker_connections 768; - # multi_accept on; -} - -http { - - ## - # Basic Settings - ## - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - variables_hash_max_size 2048; - - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - - client_max_body_size 0; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ## - # Logging Settings - ## - - access_log /config/log/nginx/access.log; - error_log /config/log/nginx/error.log; - - ## - # Gzip Settings - ## - - gzip on; - gzip_disable "msie6"; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_buffers 32 16k; - gzip_http_version 1.1; - gzip_min_length 250; - gzip_types image/jpeg image/bmp image/svg+xml text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript image/x-icon; - - # security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; - ssl_prefer_server_ciphers on; - - include /etc/nginx/conf.d/*.conf; - include /config/nginx/site-confs/*; -} - - -daemon off; From 5d37c061a2084797f91d0d0f248943640e37f239 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 02:06:25 +0530 Subject: [PATCH 012/104] build: remove pnpm --- apps/app/Dockerfile.web | 34 ++++++------------ apps/app/package.json | 1 - package.json | 3 +- yarn.lock | 76 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web index f567f5501..266789f45 100644 --- a/apps/app/Dockerfile.web +++ b/apps/app/Dockerfile.web @@ -1,4 +1,4 @@ -FROM node:alpine AS builder +FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat RUN apk update # Set working directory @@ -6,30 +6,16 @@ WORKDIR /app RUN apk add curl -RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm; - -ENV PNPM_HOME="pnpm" -ENV PATH="${PATH}:./pnpm" - COPY ./apps ./apps COPY ./package.json ./package.json COPY ./.eslintrc.json ./.eslintrc.json -COPY ./turbo.json ./turbo.json -COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml -COPY ./pnpm-lock.yaml ./pnpm-lock.yaml +COPY ./yarn.lock ./yarn.lock -RUN pnpm add -g turbo +RUN yarn global add turbo RUN turbo prune --scope=app --docker # Add lockfile and package.json's of isolated subworkspace -FROM node:alpine AS installer - -RUN apk add curl - -RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm; - -ENV PNPM_HOME="pnpm" -ENV PATH="${PATH}:./pnpm" +FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat RUN apk update @@ -38,16 +24,16 @@ WORKDIR /app # First install the dependencies (as they change less often) COPY .gitignore .gitignore COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml -RUN pnpm install +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install # Build the project COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json -# RUN pnpm add -g turbo -RUN pnpm turbo run build --filter=app... -FROM node:alpine AS runner +RUN yarn turbo run build --filter=app... + +FROM node:18-alpine AS runner WORKDIR /app # Don't run production as root @@ -63,4 +49,6 @@ COPY --from=installer /app/apps/app/package.json . COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static +EXPOSE 3000 + CMD node apps/app/server.js \ No newline at end of file diff --git a/apps/app/package.json b/apps/app/package.json index 4026aba2b..ac979ef0f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -25,7 +25,6 @@ "js-cookie": "^3.0.1", "lexical": "^0.6.4", "next": "12.2.2", - "pnpm": "^7.17.1", "prosemirror-example-setup": "^1.2.1", "prosemirror-model": "^1.18.1", "prosemirror-schema-basic": "^1.2.0", diff --git a/package.json b/package.json index 762b07e0b..7fe4250af 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,5 @@ "eslint": "^8.28.0", "eslint-config-turbo": "latest", "turbo": "latest" - }, - "packageManager": "pnpm@7.15.0" + } } diff --git a/yarn.lock b/yarn.lock index d3f43981b..5b0237717 100644 --- a/yarn.lock +++ b/yarn.lock @@ -95,6 +95,14 @@ "@lexical/utils" "0.6.4" prismjs "^1.27.0" +"@lexical/code@0.6.5", "@lexical/code@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.6.5.tgz#cfaa9dd0a08be7c79a022dd3a369d6059f292fbf" + integrity sha512-T+JPHrYfrBb6elpvmcfHTUpNfTUfPfur8BSZ+6lHObYtoC6KE5QyDMwfx7WRtDZxZ4w4sxZKTlTGb8BoGC+7WA== + dependencies: + "@lexical/utils" "0.6.5" + prismjs "^1.27.0" + "@lexical/dragon@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.6.4.tgz#a69aeaf5ab89187cf2ff3d1ac92631c91a0dec0c" @@ -121,6 +129,13 @@ dependencies: "@lexical/selection" "0.6.4" +"@lexical/html@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.6.5.tgz#93a696c56357f417028e5be624131e1d2d20b161" + integrity sha512-uR8dyIR9XyPBwgcWyv/g1GgMQtQvVVRY6telPDr7WQ1O9534IcgLj3Tp+FwdhMsontid/juIyzpBF+0BVOd9zA== + dependencies: + "@lexical/selection" "0.6.5" + "@lexical/link@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.6.4.tgz#e7c1ab092d8281cc4ccf0d371e407b3aed4a2e7d" @@ -128,6 +143,13 @@ dependencies: "@lexical/utils" "0.6.4" +"@lexical/link@0.6.5", "@lexical/link@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.6.5.tgz#7c80f20e2fa57b52b1179de73309eb414f5194b8" + integrity sha512-6TmkwqLYn5ACI87rZzl8ImtADyOimXWoWqWwUquYeGXiQyTiRhLyFVjDjTxmxi2BtXBMscoW/Lj9N4Iu7PiDtw== + dependencies: + "@lexical/utils" "0.6.5" + "@lexical/list@0.6.4", "@lexical/list@^0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.6.4.tgz#ad1f89401bc3104130baffa260c9607240f1d30a" @@ -135,6 +157,13 @@ dependencies: "@lexical/utils" "0.6.4" +"@lexical/list@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.6.5.tgz#813932c6e74fb3469b57d504a4a670411a9324c5" + integrity sha512-fCeXjZ0QuhKNuKeZv/onJf54xGHlFvfByM5KXl6ygWBP94D6y7AuspFroZRtV+2Md188cB6rhGnohyxBy8XVJg== + dependencies: + "@lexical/utils" "0.6.5" + "@lexical/mark@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.6.4.tgz#d9c9d284a6a3f236290023b39f68647756c659cb" @@ -154,6 +183,18 @@ "@lexical/text" "0.6.4" "@lexical/utils" "0.6.4" +"@lexical/markdown@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.6.5.tgz#3879ef5fcf2529c34e4cef031be69a5c30778bdf" + integrity sha512-wxmawggrgg3AsoZLOZxP55oIj/N97xCXmINVVQUVWmRYnnPvIOHdrf67v+VecHzjP3GZkLhnEfjwFTD5smoHCw== + dependencies: + "@lexical/code" "0.6.5" + "@lexical/link" "0.6.5" + "@lexical/list" "0.6.5" + "@lexical/rich-text" "0.6.5" + "@lexical/text" "0.6.5" + "@lexical/utils" "0.6.5" + "@lexical/offset@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.6.4.tgz#06fdf49e8e18135e82c9925c6b168e7e012a5420" @@ -198,11 +239,21 @@ resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.6.4.tgz#d00e2621d0113ed842178f505618060cde2f2b0f" integrity sha512-GUTAEUPmSKzL1kldvdHqM9IgiAJC1qfMeDQFyUS2xwWKQnid0nVeUZXNxyBwxZLyOcyDkx5dXp9YiEO6X4x+TQ== +"@lexical/rich-text@0.6.5", "@lexical/rich-text@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.6.5.tgz#3e44477995972311df1fcb4c04c0d9112dcb23eb" + integrity sha512-UFV+dZmEW05AaF3lH96nXzigzH5zEDZCzFy/BMt0QNLb3q1eIy+EahvA8dOMn9IL/CsBOh2XYl8l7k6lwe3W5Q== + "@lexical/selection@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.6.4.tgz#2a3c8537c1e9e8bf492ccd6fbaafcfb02fea231a" integrity sha512-dmrIQCQJOKARS7VRyE9WEKRaqP8SG9Xtzm8Bsk6+P0up1yWzlUvww+2INKr0bUUeQmI7DJxo5PX68qcoLeTAUg== +"@lexical/selection@0.6.5", "@lexical/selection@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.6.5.tgz#28d1be754bb8be73d073c8efbc631c097a79dfb1" + integrity sha512-PvDLxbnHCDET/9UQp1Od4R5wakc6GgTeIKPxkfMyw9eF+vr8xFELvWvOadfhcCb+ydp5IMqqsSZ7eSCl8wFODg== + "@lexical/table@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.6.4.tgz#a07b642899e40c5981ab81b6ac541944bfef19ed" @@ -210,11 +261,23 @@ dependencies: "@lexical/utils" "0.6.4" +"@lexical/table@0.6.5", "@lexical/table@^0.6.4": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.6.5.tgz#ab4f670c4bc99f8180d4722f334a2078dad5609b" + integrity sha512-dAsI/ut50li/8xvgIDUo8uzLChvhB3WoyK3zxJ+ywFDzjdDSIshyhvVgQFvkJP3wJLIOSfwhqggnwxDnxLqBQQ== + dependencies: + "@lexical/utils" "0.6.5" + "@lexical/text@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.6.4.tgz#49267e7a9395720b6361ca12631e0370c118c03a" integrity sha512-gCANONCi3J2zf+yxv2CPEj2rsxpUBgQuR4TymGjsoVFsHqjRc3qHF5lNlbpWjPL5bJDSHFJySwn4/P20GNWggg== +"@lexical/text@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.6.5.tgz#be3edf9ac027c525c5544af426f39850a3673917" + integrity sha512-cBADZKXk09hoDXPZarcp65byWKZjBQFHgtWz4aIJScfdD25/LqoQ815tHBAqouAWDMiTOUjq07MFfNS3OHc3vw== + "@lexical/utils@0.6.4", "@lexical/utils@^0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.6.4.tgz#7e1d79dd112efbcc048088714a3dc8e403b5aee5" @@ -223,6 +286,14 @@ "@lexical/list" "0.6.4" "@lexical/table" "0.6.4" +"@lexical/utils@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.6.5.tgz#a5de151098addbd6e0078d888721c09008c87ba8" + integrity sha512-G/PBON7SeGoKs7yYbyLNtJE7CltxuXHWfw7F9vUk0avCzoSTrBeMNkmIOhnyp8XPuT1/5hgNWP8IG7kMsgozEg== + dependencies: + "@lexical/list" "0.6.5" + "@lexical/table" "0.6.5" + "@lexical/yjs@0.6.4": version "0.6.4" resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.6.4.tgz#0773a7a7abbd3d32bb16e7ff84d9f89ee2284996" @@ -1999,11 +2070,6 @@ pify@^2.3.0: resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== -pnpm@^7.17.1: - version "7.17.1" - resolved "https://registry.yarnpkg.com/pnpm/-/pnpm-7.17.1.tgz#6e0cd7b9f2cbd93a7fe60121e328e5281a03c903" - integrity sha512-O76jPxzoeja81Z/8YyTfuXt+f7qkpsyEJsNBreWYBLHY5rJkjvNE/bIUGQ2uD/rcYPEtmrZZYox21OjAMC9EGw== - postcss-import@^14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0" From ddd44a86bb1a904674c02d0c20a74977736a26b7 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 02:13:07 +0530 Subject: [PATCH 013/104] build: remove Pillow --- apiserver/requirements/base.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 60f7b928e..e845e4561 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -8,7 +8,6 @@ django-oauth-toolkit==2.0.0 mistune==2.0.2 djangorestframework==3.13.1 redis==4.2.2 -Pillow==9.1.0 django-nested-admin==3.4.0 django-cors-headers==3.11.0 whitenoise==6.0.0 From ada68a9bf7297ec610b19c25f8d4bbd0bb68bf31 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Tue, 13 Dec 2022 12:47:35 +0530 Subject: [PATCH 014/104] refractor: removed redirection logic from AppLayout --- apps/app/layouts/AppLayout.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/app/layouts/AppLayout.tsx b/apps/app/layouts/AppLayout.tsx index dbdddf0a7..e5135a605 100644 --- a/apps/app/layouts/AppLayout.tsx +++ b/apps/app/layouts/AppLayout.tsx @@ -1,9 +1,4 @@ -// react -import React, { useEffect, useState } from "react"; -// next -import { useRouter } from "next/router"; -// hooks -import useUser from "lib/hooks/useUser"; +import React, { useState } from "react"; // layouts import Container from "layouts/Container"; import Sidebar from "layouts/Navbar/Sidebar"; @@ -15,14 +10,6 @@ import type { Props } from "./types"; const AppLayout: React.FC = ({ meta, children, noPadding = false, bg = "primary" }) => { const [isOpen, setIsOpen] = useState(false); - const router = useRouter(); - - const { user, isUserLoading } = useUser(); - - useEffect(() => { - if (!isUserLoading && (!user || user === null)) router.push("/signin"); - }, [isUserLoading, user, router]); - return ( From db10c884e8be077e92ff30514d8c3e36f715d958 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 13:27:23 +0530 Subject: [PATCH 015/104] build: seperate worker process --- apiserver/bin/worker | 6 ++++++ docker-compose.yml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100755 apiserver/bin/worker diff --git a/apiserver/bin/worker b/apiserver/bin/worker new file mode 100755 index 000000000..17b42fd9b --- /dev/null +++ b/apiserver/bin/worker @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +python manage.py migrate + +python manage.py rqworker \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2963907be..d1cdd3314 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,7 +71,7 @@ services: image: plane-api depends_on: - redis - command: python manage.py rqworker + command: ./bin/worker links: - redis environment: From b5a33d8f4d3d50c58b2a4b55619be29f682a759a Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Tue, 13 Dec 2022 14:18:49 +0530 Subject: [PATCH 016/104] fix: mutation in issue comments --- .../issue-detail/comment/IssueCommentCard.tsx | 19 ------------------ .../comment/IssueCommentSection.tsx | 20 ++++++++++++------- apps/app/constants/fetch-keys.ts | 2 +- .../projects/[projectId]/issues/[issueId].tsx | 2 +- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx index e911ae306..90c3b0bed 100644 --- a/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx +++ b/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx @@ -1,16 +1,12 @@ import React, { useEffect, useState } from "react"; // next import Image from "next/image"; -// swr -import { mutate } from "swr"; // headless ui import { Menu } from "@headlessui/react"; // react hook form import { useForm } from "react-hook-form"; // hooks import useUser from "lib/hooks/useUser"; -// fetch keys -import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys"; // common import { timeAgo } from "constants/common"; // ui @@ -42,16 +38,6 @@ const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion const onEnter = (formData: IIssueComment) => { if (isSubmitting) return; - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => { - const newData = prevData ?? []; - const index = newData.findIndex((comment) => comment.id === formData.id); - newData[index] = formData; - return [...newData]; - }, - false - ); setIsEditing(false); onSubmit(formData); }; @@ -155,11 +141,6 @@ const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion className="w-full text-left py-2 pl-2" type="button" onClick={() => { - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => (prevData ?? []).filter((c) => c.id !== comment.id), - false - ); handleCommentDeletion(comment.id); }} > diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx index fe07421f1..d70196b28 100644 --- a/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx +++ b/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx @@ -13,8 +13,6 @@ import CommentCard from "components/project/issues/issue-detail/comment/IssueCom import { TextArea, Button, Spinner } from "ui"; // types import type { IIssueComment } from "types"; -// icons -import UploadingIcon from "public/animated-icons/uploading.json"; type Props = { comments?: IIssueComment[]; @@ -41,11 +39,10 @@ const IssueCommentSection: React.FC = ({ comments, issueId, projectId, wo .createIssueComment(workspaceSlug, projectId, issueId, formData) .then((response) => { console.log(response); - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => [...(prevData ?? []), response], - false - ); + mutate(PROJECT_ISSUES_COMMENTS(issueId), (prevData) => [ + response, + ...(prevData ?? []), + ]); reset(defaultValues); }) .catch((error) => { @@ -58,6 +55,12 @@ const IssueCommentSection: React.FC = ({ comments, issueId, projectId, wo .patchIssueComment(workspaceSlug, projectId, issueId, comment.id, comment) .then((response) => { console.log(response); + mutate(PROJECT_ISSUES_COMMENTS(issueId), (prevData) => { + const newData = prevData ?? []; + const index = newData.findIndex((comment) => comment.id === response.id); + newData[index] = response; + return [...newData]; + }); }); }; @@ -65,6 +68,9 @@ const IssueCommentSection: React.FC = ({ comments, issueId, projectId, wo await issuesServices .deleteIssueComment(workspaceSlug, projectId, issueId, commentId) .then((response) => { + mutate(PROJECT_ISSUES_COMMENTS(issueId), (prevData) => + (prevData ?? []).filter((c) => c.id !== commentId) + ); console.log(response); }); }; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 76a3807ae..781efe2fc 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -17,7 +17,7 @@ export const PROJECT_ISSUES_LIST = (workspaceSlug: string, projectId: string) => export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId}`; export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => `PROJECT_ISSUES_PROPERTIES_${projectId}`; -export const PROJECT_ISSUES_COMMENTS = "PROJECT_ISSUES_COMMENTS"; +export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMMENTS_${issueId}`; export const PROJECT_ISSUES_ACTIVITY = "PROJECT_ISSUES_ACTIVITY"; export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`; export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`; diff --git a/apps/app/pages/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/projects/[projectId]/issues/[issueId].tsx index 74dcc8dce..1f0a6ae96 100644 --- a/apps/app/pages/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/projects/[projectId]/issues/[issueId].tsx @@ -115,7 +115,7 @@ const IssueDetail: NextPage = () => { ); const { data: issueComments } = useSWR( - activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS : null, + activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS(issueId as string) : null, activeWorkspace && projectId && issueId ? () => issuesServices.getIssueComments( From c9b1a2590a0c487703264a4ddffd7ad65db06e37 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Tue, 13 Dec 2022 18:45:23 +0530 Subject: [PATCH 017/104] feat: create/update state according to group from project settings pages refractor: followed naming convension and made components easy to use --- ...eDeletion.tsx => confirm-state-delete.tsx} | 12 +- ...odal.tsx => create-update-state-modal.tsx} | 21 +- .../issues/CreateUpdateIssueModal/index.tsx | 2 +- .../project/settings/StatesSettings.tsx | 315 +++++++++++++++--- apps/app/constants/index.ts | 8 + 5 files changed, 296 insertions(+), 62 deletions(-) rename apps/app/components/project/issues/BoardView/state/{ConfirmStateDeletion.tsx => confirm-state-delete.tsx} (95%) rename apps/app/components/project/issues/BoardView/state/{CreateUpdateStateModal.tsx => create-update-state-modal.tsx} (91%) diff --git a/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx similarity index 95% rename from apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx rename to apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx index 448c50acc..d39962ca1 100644 --- a/apps/app/components/project/issues/BoardView/state/ConfirmStateDeletion.tsx +++ b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx @@ -18,11 +18,11 @@ import { Button } from "ui"; import type { IState } from "types"; type Props = { isOpen: boolean; - setIsOpen: React.Dispatch>; - data?: IState; + onClose: () => void; + data: IState | null; }; -const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { +const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const { activeWorkspace } = useUser(); @@ -30,7 +30,7 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { const cancelButtonRef = useRef(null); const handleClose = () => { - setIsOpen(false); + onClose(); setIsDeleteLoading(false); }; @@ -53,10 +53,6 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { }); }; - useEffect(() => { - data && setIsOpen(true); - }, [data, setIsOpen]); - return ( = { name: "", description: "", color: "#000000", + group: "backlog", }; const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, handleClose }) => { @@ -161,6 +164,22 @@ const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, hand }} />
+
+ + + + +
+ ); +}; + +const StatesSettings: React.FC = ({ projectId }) => { + const [activeGroup, setActiveGroup] = useState(null); + const [selectedState, setSelectedState] = useState(null); + const [selectDeleteState, setSelectDeleteState] = useState(null); + + const { states, activeWorkspace } = useUser(); + + const groupedStates: { + [key: string]: Array; + } = groupBy(states ?? [], "group"); return ( <> - { - setSelectedState(undefined); - setIsCreateStateModal(false); - }} - projectId={projectId as string} - data={selectedState ? states?.find((state) => state.id === selectedState) : undefined} + state.id === selectDeleteState) ?? null} + onClose={() => setSelectDeleteState(null)} /> +

State

Manage the state of this project.

-
-
- {states?.map((state) => ( -
-
-
-

{addSpaceIfCamelCase(state.name)}

-
-
- -
+
+ {Object.keys(groupedStates).map((key) => ( +
+
+

{key} states

+
- ))} - -
+ {groupedStates[key]?.map((state) => + state.id !== selectedState ? ( +
+
+
+

{addSpaceIfCamelCase(state.name)}

+
+
+ + +
+
+ ) : ( + { + setActiveGroup(null); + setSelectedState(null); + }} + workspaceSlug={activeWorkspace?.slug} + data={states?.find((state) => state.id === selectedState) ?? null} + selectedGroup={key as keyof StateGroup} + /> + ) + )} + {key === activeGroup && ( + { + setActiveGroup(null); + setSelectedState(null); + }} + workspaceSlug={activeWorkspace?.slug} + data={null} + selectedGroup={key as keyof StateGroup} + /> + )} +
+ ))}
diff --git a/apps/app/constants/index.ts b/apps/app/constants/index.ts index 603652e9f..0e9a67788 100644 --- a/apps/app/constants/index.ts +++ b/apps/app/constants/index.ts @@ -8,3 +8,11 @@ export const ROLE = { }; export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; + +export const GROUP_CHOICES = { + backlog: "Backlog", + unstarted: "Unstarted", + started: "Started", + completed: "Completed", + cancelled: "Cancelled", +}; From 4414a71331754cacf44c651e85b8b3683efdfdb6 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 20:47:49 +0530 Subject: [PATCH 018/104] build: updated config for redis and wait_for_db command --- apiserver/bin/takeoff | 2 +- apiserver/bin/worker | 2 +- apiserver/plane/db/management/__init__.py | 0 .../plane/db/management/commands/__init__.py | 0 .../db/management/commands/wait_for_db.py | 19 ++++++++++++++ apiserver/plane/settings/redis.py | 12 +-------- docker-compose.yml | 26 +++++++++++++------ 7 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 apiserver/plane/db/management/__init__.py create mode 100644 apiserver/plane/db/management/commands/__init__.py create mode 100644 apiserver/plane/db/management/commands/wait_for_db.py diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index d6106003e..b76a9fd3b 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -1,5 +1,5 @@ #!/bin/bash set -e - +python manage.py wait_for_db python manage.py migrate exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/worker b/apiserver/bin/worker index 17b42fd9b..25a947613 100755 --- a/apiserver/bin/worker +++ b/apiserver/bin/worker @@ -1,6 +1,6 @@ #!/bin/bash set -e +python manage.py wait_for_db python manage.py migrate - python manage.py rqworker \ No newline at end of file diff --git a/apiserver/plane/db/management/__init__.py b/apiserver/plane/db/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/management/commands/__init__.py b/apiserver/plane/db/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/management/commands/wait_for_db.py b/apiserver/plane/db/management/commands/wait_for_db.py new file mode 100644 index 000000000..365452a7a --- /dev/null +++ b/apiserver/plane/db/management/commands/wait_for_db.py @@ -0,0 +1,19 @@ +import time +from django.db import connections +from django.db.utils import OperationalError +from django.core.management import BaseCommand + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def handle(self, *args, **options): + self.stdout.write('Waiting for database...') + db_conn = None + while not db_conn: + try: + db_conn = connections['default'] + except OperationalError: + self.stdout.write('Database unavailable, waititng 1 second...') + time.sleep(1) + + self.stdout.write(self.style.SUCCESS('Database available!')) diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py index c1eb1b59a..390a075c8 100644 --- a/apiserver/plane/settings/redis.py +++ b/apiserver/plane/settings/redis.py @@ -6,17 +6,7 @@ from urllib.parse import urlparse def redis_instance(): if settings.REDIS_URL: - tls_url = os.environ.get("REDIS_TLS_URL", False) - url = urlparse(settings.REDIS_URL) - if tls_url: - url = urlparse(tls_url) - ri = redis.Redis( - host=url.hostname, - port=url.port, - password=url.password, - ssl=True, - ssl_cert_reqs=None, - ) + ri = redis.from_url(settings.REDIS_URL, db=0) else: ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) diff --git a/docker-compose.yml b/docker-compose.yml index d1cdd3314..757033e4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,19 +3,24 @@ version: "3.8" services: db: image: postgres:12-alpine - restart: on-failure + restart: always volumes: - - postgres-data:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql/data environment: POSTGRES_USER: plane POSTGRES_DB: plane POSTGRES_PASSWORD: plane command: postgres -c 'max_connections=1000' + ports: + - "5432:5432" redis: image: redis:6.2.7-alpine - restart: on-failure - command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb + restart: always + ports: + - "6379:6379" + volumes: + - redisdata:/data plane-web: image: plane-web @@ -64,16 +69,20 @@ services: - redis command: ./bin/takeoff links: - - db - - redis + - db:db + - redis:redis plane-worker: image: plane-api + container_name: plane-bg depends_on: - redis + - db + - plane-api command: ./bin/worker links: - - redis + - redis:redis + - db:db environment: SENTRY_DSN: $SENTRY_DSN WEB_URL: $WEB_URL @@ -94,4 +103,5 @@ services: volumes: - postgres-data: + pgdata: + redisdata: From 5eea5529edf8b6172d6362eff0ea6ee9a744c58f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 21:30:50 +0530 Subject: [PATCH 019/104] build: update background worker container name and create app.json for heroku deployments --- app.json | 16 ++++++++++++++++ docker-compose.yml | 2 +- heroku.yml | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 app.json diff --git a/app.json b/app.json new file mode 100644 index 000000000..884412fa6 --- /dev/null +++ b/app.json @@ -0,0 +1,16 @@ +{ + "name": "Plane", + "description": "Plane helps you track your issues, epics, and product roadmaps.", + "repository": "http://github.com/makeplane/plane", + "logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4", + "website": "https://plane.so/", + "success_url": "/", + "stack": "container", + "keywords": ["plane", "project management", "django", "next"], + "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], + "buildpacks": [ + { + "url": "https://github.com/heroku/heroku-buildpack-python.git" + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index 757033e4b..fe329cf9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,7 +74,7 @@ services: plane-worker: image: plane-api - container_name: plane-bg + container_name: plane-rqworker depends_on: - redis - db diff --git a/heroku.yml b/heroku.yml index f308cbe08..c31c4111e 100644 --- a/heroku.yml +++ b/heroku.yml @@ -17,4 +17,4 @@ release: run: plane-frontend: node apps/app/server.js plane-backend: ./apiserver/bin/takeoff - plane-worker: python manage.py rqworker \ No newline at end of file + plane-rqworker: ./apiserver/bin/worker \ No newline at end of file From e36641cbf03bfef24f3bbd3c8309a4e1496dcc41 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:13:26 +0530 Subject: [PATCH 020/104] build: plane test github actions --- .github/workflows/test_runner.yml | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/test_runner.yml diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml new file mode 100644 index 000000000..5f22fc80f --- /dev/null +++ b/.github/workflows/test_runner.yml @@ -0,0 +1,49 @@ +name: Plane Tests + +on: + push: + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: github_actions + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + redis: + image: redis:latest + env: + REDIS_HOST: localhost + REDIS_PORT: 6379 + ports: + - 6379:6379 + options: --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: psycopg2 prerequisites + run: sudo apt-get install libpq-dev + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/test.txt + - name: Run Tests + run: coverage run --source='.' manage.py test --settings=plane.settings.test From cb640407b6ecf165ce4115c59adc46a2c0c09b8b Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:20:43 +0530 Subject: [PATCH 021/104] feat: group states into predefined state types --- apiserver/plane/api/views/project.py | 10 ++++------ apiserver/plane/db/models/state.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 081371984..611ec05da 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -90,12 +90,10 @@ class ProjectViewSet(BaseViewSet): ## Default states states = [ - {"name": "Backlog", "color": "#5e6ad2", "sequence": 15000}, - {"name": "ToDo", "color": "#eb5757", "sequence": 25000}, - {"name": "Started", "color": "#26b5ce", "sequence": 35000}, - {"name": "InProgress", "color": "#f2c94c", "sequence": 45000}, - {"name": "Done", "color": "#4cb782", "sequence": 55000}, - {"name": "Cancelled", "color": "#cc1d10", "sequence": 65000}, + {"name": "Todo", "color": "#eb5757", "sequence": 25000}, + {"name": "In Progress", "color": "#26b5ce", "sequence": 35000}, + {"name": "Done", "color": "#f2c94c", "sequence": 45000}, + {"name": "Cancelled", "color": "#4cb782", "sequence": 55000}, ] State.objects.bulk_create( diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 42142364f..dd1223394 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -12,6 +12,16 @@ class State(ProjectBaseModel): color = models.CharField(max_length=255, verbose_name="State Color") slug = models.SlugField(max_length=100, blank=True) sequence = models.FloatField(default=65535) + group = models.CharField( + choices=( + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="backlog", + ) def __str__(self): """Return name of the state""" From a5f071311dfa4fbadaf0e7a825c1f7ae8ade7bfa Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:22:34 +0530 Subject: [PATCH 022/104] dev: add max length field and group in default project states --- apiserver/plane/api/views/project.py | 34 ++++++++++++++++++++++++---- apiserver/plane/db/models/state.py | 1 + 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 611ec05da..b94901eca 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -90,10 +90,36 @@ class ProjectViewSet(BaseViewSet): ## Default states states = [ - {"name": "Todo", "color": "#eb5757", "sequence": 25000}, - {"name": "In Progress", "color": "#26b5ce", "sequence": 35000}, - {"name": "Done", "color": "#f2c94c", "sequence": 45000}, - {"name": "Cancelled", "color": "#4cb782", "sequence": 55000}, + { + "name": "Backlog", + "color": "#5e6ad2", + "sequence": 15000, + "group": "backlog", + }, + { + "name": "Todo", + "color": "#eb5757", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#26b5ce", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#f2c94c", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#4cb782", + "sequence": 55000, + "group": "cancelled", + }, ] State.objects.bulk_create( diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index dd1223394..7a62badd8 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -21,6 +21,7 @@ class State(ProjectBaseModel): ("cancelled", "Cancelled"), ), default="backlog", + max_length=20, ) def __str__(self): From ddde7a7eea2a66021004a4639c27020bc45b49d9 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:24:42 +0530 Subject: [PATCH 023/104] feat: add cycle id and cycle details in issue list page --- apiserver/plane/api/serializers/issue.py | 35 ++++++++++++++++++++++++ apiserver/plane/api/views/issue.py | 10 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 6677f47cb..6315564ce 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -19,6 +19,8 @@ from plane.db.models import ( IssueLabel, Label, IssueBlocker, + Cycle, + CycleIssue, ) @@ -302,6 +304,38 @@ class IssueAssigneeSerializer(BaseSerializer): fields = "__all__" +class CycleBaseSerializer(BaseSerializer): + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + +class IssueCycleDetailSerializer(BaseSerializer): + + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + + class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") @@ -310,6 +344,7 @@ class IssueSerializer(BaseSerializer): assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) blocked_issues = BlockedIssueSerializer(read_only=True, many=True) blocker_issues = BlockerIssueSerializer(read_only=True, many=True) + issue_cycle = IssueCycleDetailSerializer(read_only=True) class Meta: model = Issue diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index c6c30e867..e5465b15f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -36,6 +36,7 @@ from plane.db.models import ( IssueProperty, Label, IssueBlocker, + CycleIssue, ) @@ -89,6 +90,12 @@ class IssueViewSet(BaseViewSet): queryset=IssueBlocker.objects.select_related("block", "blocked_by"), ) ) + .prefetch_related( + Prefetch( + "issue_cycle", + queryset=CycleIssue.objects.select_related("cycle", "issue"), + ), + ) ) def grouper(self, issue, group_by): @@ -384,7 +391,8 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): issues.delete() return Response( - {"message": f"{total_issues} issues were deleted"}, status=status.HTTP_200_OK + {"message": f"{total_issues} issues were deleted"}, + status=status.HTTP_200_OK, ) except Exception as e: capture_exception(e) From 2335dfe8841dcf66de2580dfd3006fa3ba56dfda Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:25:46 +0530 Subject: [PATCH 024/104] fix: partial update for projects --- apiserver/plane/api/views/project.py | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index b94901eca..28878f275 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -158,6 +158,42 @@ class ProjectViewSet(BaseViewSet): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + def partial_update(self, request, slug, pk=None): + try: + workspace = Workspace.objects.get(slug=slug) + + project = Project.objects.get(pk=pk) + + serializer = ProjectSerializer( + project, + data={**request.data}, + context={"workspace_id": workspace.id}, + 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 IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_410_GONE, + ) + except serializers.ValidationError as e: + return Response( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_410_GONE, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + class InviteProjectEndpoint(BaseAPIView): From a45fb440207c788aa2d6f1eb6111f190239c953c Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:27:59 +0530 Subject: [PATCH 025/104] feat: create column and endpoint to store user project issue views --- apiserver/plane/api/views/project.py | 33 ++++++++++++++++++++++++++++ apiserver/plane/db/models/project.py | 1 + 2 files changed, 34 insertions(+) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 28878f275..0b3fb1113 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -584,3 +584,36 @@ class ProjectJoinEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + try: + + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter( + member=request.user, project=project + ).first() + + if project_member is None: + return Response( + {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN + ) + + project_member.view_props = request.data + + project_member.save() + + return Response(status=status.HTTP_200_OK) + + except Project.DoesNotExist: + return Response( + {"error": "The requested resource does not exists"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 9e8913dd5..2e253ea1b 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -116,6 +116,7 @@ class ProjectMember(ProjectBaseModel): ) comment = models.TextField(blank=True, null=True) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + view_props = models.JSONField(null=True) class Meta: unique_together = ["project", "member"] From c99eb494c85e61a8e3cffb20ba2866aba00fb3cf Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:28:56 +0530 Subject: [PATCH 026/104] dev: add migrations for new model attributes --- .../db/migrations/0009_auto_20221213_2328.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apiserver/plane/db/migrations/0009_auto_20221213_2328.py diff --git a/apiserver/plane/db/migrations/0009_auto_20221213_2328.py b/apiserver/plane/db/migrations/0009_auto_20221213_2328.py new file mode 100644 index 000000000..077ab7e82 --- /dev/null +++ b/apiserver/plane/db/migrations/0009_auto_20221213_2328.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2022-12-13 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0008_label_colour'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='view_props', + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name='state', + name='group', + field=models.CharField(choices=[('backlog', 'Backlog'), ('unstarted', 'Unstarted'), ('started', 'Started'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='backlog', max_length=20), + ), + ] From 7613cc52a69d702cb73e8c1dc310ea55e3df67b1 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:32:10 +0530 Subject: [PATCH 027/104] fix: include urls for project views --- apiserver/plane/api/urls.py | 6 ++++++ apiserver/plane/api/views/__init__.py | 1 + 2 files changed, 7 insertions(+) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 560fb78cb..6aa0d5aea 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -54,6 +54,7 @@ from plane.api.views import ( ProjectJoinEndpoint, BulkDeleteIssuesEndpoint, BulkAssignIssuesToCycleEndpoint, + ProjectUserViewsEndpoint, ) from plane.api.views.project import AddTeamToProjectEndpoint @@ -312,6 +313,11 @@ urlpatterns = [ ), name="project", ), + path( + "workspaces//projects//project-views/", + ProjectUserViewsEndpoint.as_view(), + name="project-view", + ), # States path( "workspaces//projects//states/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 171b1b408..f4112178a 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -9,6 +9,7 @@ from .project import ( ProjectIdentifierEndpoint, AddMemberToProjectEndpoint, ProjectJoinEndpoint, + ProjectUserViewsEndpoint, ) from .people import ( PeopleEndpoint, From 2a2e6f78e0541728354ac051300ce6e548cc888f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:32:49 +0530 Subject: [PATCH 028/104] fix: project default state grouping on project create --- apiserver/plane/api/views/project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 0b3fb1113..1dc00e404 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -130,6 +130,7 @@ class ProjectViewSet(BaseViewSet): project=serializer.instance, sequence=state["sequence"], workspace=serializer.instance.workspace, + group=state["group"], ) for state in states ] From d2932a44a67cf3db5ae21ca5f7553fdc61a54243 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:33:58 +0530 Subject: [PATCH 029/104] chore: update python runtime to secure 3.9.16 --- runtime.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime.txt b/runtime.txt index b5f092a96..e02524340 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.9.15 \ No newline at end of file +python-3.9.16 \ No newline at end of file From eb85e04a099edbcd9c511c0de1b5db4c370ffa11 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:36:30 +0530 Subject: [PATCH 030/104] fix: segregate my issues with workspace --- apiserver/plane/api/urls.py | 6 ++++++ apiserver/plane/api/views/__init__.py | 3 ++- apiserver/plane/api/views/issue.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 6aa0d5aea..68ce32bd0 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -55,6 +55,7 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, + UserWorkSpaceIssues, ) from plane.api.views.project import AddTeamToProjectEndpoint @@ -495,6 +496,11 @@ urlpatterns = [ "workspaces//projects//bulk-delete-issues/", BulkDeleteIssuesEndpoint.as_view(), ), + path( + "workspaces//me/issues/", + UserWorkSpaceIssues.as_view(), + name="workspace-issues", + ), ## End Issues ## Issue Activity path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index f4112178a..a6bb70af0 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -9,7 +9,7 @@ from .project import ( ProjectIdentifierEndpoint, AddMemberToProjectEndpoint, ProjectJoinEndpoint, - ProjectUserViewsEndpoint, + ProjectUserViewsEndpoint, ) from .people import ( PeopleEndpoint, @@ -48,6 +48,7 @@ from .issue import ( IssuePropertyViewSet, LabelViewSet, BulkDeleteIssuesEndpoint, + UserWorkSpaceIssues, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e5465b15f..530685a0f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -173,6 +173,21 @@ class UserIssuesEndpoint(BaseAPIView): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) +class UserWorkSpaceIssues(BaseAPIView): + def get(self, request, slug): + try: + print(request.user) + issues = Issue.objects.filter( + assignees__in=[request.user], workspace__slug=slug + ) + serializer = IssueSerializer(issues, 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_500_INTERNAL_SERVER_ERROR, + ) class WorkSpaceIssuesEndpoint(BaseAPIView): From 36817e616bfbec087b8b834df32dafaf7f352390 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:42:41 +0530 Subject: [PATCH 031/104] fix: workspace and project uniqueness --- apiserver/plane/api/serializers/project.py | 18 ++++++++++++------ apiserver/plane/api/views/project.py | 20 ++++++++++++++------ apiserver/plane/db/models/project.py | 10 ++++++++-- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 34afca72b..cdc9adf36 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -29,12 +29,18 @@ class ProjectSerializer(BaseSerializer): if identifier == "": raise serializers.ValidationError(detail="Project Identifier is required") - if ProjectIdentifier.objects.filter(name=identifier).exists(): + if ProjectIdentifier.objects.filter( + name=identifier, workspace_id=self.context["workspace_id"] + ).exists(): raise serializers.ValidationError(detail="Project Identifier is taken") project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] ) - _ = ProjectIdentifier.objects.create(name=project.identifier, project=project) + _ = ProjectIdentifier.objects.create( + name=project.identifier, + project=project, + workspace_id=self.context["workspace_id"], + ) return project def update(self, instance, validated_data): @@ -47,7 +53,9 @@ class ProjectSerializer(BaseSerializer): return project # If no Project Identifier is found create it - project_identifier = ProjectIdentifier.objects.filter(name=identifier).first() + project_identifier = ProjectIdentifier.objects.filter( + name=identifier, workspace_id=instance.workspace_id + ).first() if project_identifier is None: project = super().update(instance, validated_data) @@ -61,9 +69,7 @@ class ProjectSerializer(BaseSerializer): return project # If not same fail update - raise serializers.ValidationError( - detail="Project Identifier is already taken" - ) + raise serializers.ValidationError(detail="Project Identifier is already taken") class ProjectDetailSerializer(BaseSerializer): diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 1dc00e404..0685cebe4 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -144,9 +144,13 @@ class ProjectViewSet(BaseViewSet): except IntegrityError as e: if "already exists" in str(e): return Response( - {"name": "The project name is already taken"}, + {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) + except Workspace.DoesNotExist as e: + return Response( + {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND + ) except serializers.ValidationError as e: return Response( {"identifier": "The project identifier is already taken"}, @@ -183,6 +187,10 @@ class ProjectViewSet(BaseViewSet): {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) + except (Project.DoesNotExist or Workspace.DoesNotExist) as e: + return Response( + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + ) except serializers.ValidationError as e: return Response( {"identifier": "The project identifier is already taken"}, @@ -498,9 +506,9 @@ class ProjectIdentifierEndpoint(BaseAPIView): {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) - exists = ProjectIdentifier.objects.filter(name=name).values( - "id", "name", "project" - ) + exists = ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).values("id", "name", "project") return Response( {"exists": len(exists), "identifiers": exists}, @@ -523,13 +531,13 @@ class ProjectIdentifierEndpoint(BaseAPIView): {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) - if Project.objects.filter(identifier=name).exists(): + if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): return Response( {"error": "Cannot delete an identifier of an existing project"}, status=status.HTTP_400_BAD_REQUEST, ) - ProjectIdentifier.objects.filter(name=name).delete() + ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() return Response( status=status.HTTP_204_NO_CONTENT, diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 2e253ea1b..02fea2c7c 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -35,7 +35,8 @@ class Project(BaseModel): "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" ) identifier = models.CharField( - max_length=5, verbose_name="Project Identifier", null=True, blank=True + max_length=5, + verbose_name="Project Identifier", ) slug = models.SlugField(max_length=100, blank=True) default_assignee = models.ForeignKey( @@ -58,7 +59,7 @@ class Project(BaseModel): return f"{self.name} <{self.workspace.name}>" class Meta: - unique_together = ["name", "workspace"] + unique_together = ["identifier", "workspace"] verbose_name = "Project" verbose_name_plural = "Projects" db_table = "project" @@ -131,12 +132,17 @@ class ProjectMember(ProjectBaseModel): class ProjectIdentifier(AuditModel): + + workspace = models.ForeignKey( + "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True + ) project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" ) name = models.CharField(max_length=10) class Meta: + unique_together = ["name", "workspace"] verbose_name = "Project Identifier" verbose_name_plural = "Project Identifiers" db_table = "project_identifier" From 5c91e0bef32b92600cbbcf5f1ad9860e63115f8c Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:45:41 +0530 Subject: [PATCH 032/104] feat: last visited workspace and project details in a single endpoint --- apiserver/plane/api/urls.py | 6 ++++ apiserver/plane/api/views/__init__.py | 1 + apiserver/plane/api/views/project.py | 1 - apiserver/plane/api/views/workspace.py | 48 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 68ce32bd0..3a7e02f62 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -56,6 +56,7 @@ from plane.api.views import ( BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, UserWorkSpaceIssues, + UserLastProjectWithWorkspaceEndpoint, ) from plane.api.views.project import AddTeamToProjectEndpoint @@ -233,6 +234,11 @@ urlpatterns = [ ), name="workspace", ), + path( + "users/last-visited-workspace/", + UserLastProjectWithWorkspaceEndpoint.as_view(), + name="workspace-project-details", + ), ## End Workspaces ## # Projects path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index a6bb70af0..c2efaa707 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -32,6 +32,7 @@ from .workspace import ( WorkspaceInvitationsViewset, UserWorkspaceInvitationsEndpoint, UserWorkspaceInvitationEndpoint, + UserLastProjectWithWorkspaceEndpoint, ) from .state import StateViewSet from .shortcut import ShortCutViewSet diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 0685cebe4..dafc62743 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -22,7 +22,6 @@ from plane.api.serializers import ( ProjectMemberSerializer, ProjectDetailSerializer, ProjectMemberInviteSerializer, - ProjectIdentifierSerializer, ) from plane.api.permissions import ProjectBasePermission diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 8e10a72b9..2a412ec76 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -26,6 +26,7 @@ from plane.api.serializers import ( TeamSerializer, WorkSpaceMemberInviteSerializer, UserLiteSerializer, + ProjectMemberSerializer, ) from plane.api.views.base import BaseAPIView from . import BaseViewSet @@ -35,6 +36,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, Team, + ProjectMember, ) from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission from plane.bgtasks.workspace_invitation_task import workspace_invitation @@ -460,3 +462,49 @@ class UserWorkspaceInvitationEndpoint(BaseViewSet): .filter(pk=self.kwargs.get("pk")) .select_related("workspace") ) + + +class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): + def get(self, request): + try: + + user = User.objects.get(pk=request.user.id) + + last_workspace_id = user.last_workspace_id + + if last_workspace_id is None: + return Response( + { + "project_details": [], + "workspace_details": {}, + }, + status=status.HTTP_200_OK, + ) + + workspace = Workspace.objects.get(pk=last_workspace_id) + workspace_serializer = WorkSpaceSerializer(workspace) + + project_member = ProjectMember.objects.filter( + workspace_id=last_workspace_id, member=request.user + ).select_related("workspace", "project", "member") + + project_member_serializer = ProjectMemberSerializer( + project_member, many=True + ) + + return Response( + { + "workspace_details": workspace_serializer.data, + "project_details": project_member_serializer.data, + }, + status=status.HTTP_200_OK, + ) + + except User.DoesNotExist: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) \ No newline at end of file From 5c2d0a829aaab9c4d600636f800896e637d6a0fb Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:46:51 +0530 Subject: [PATCH 033/104] refactor: remove unused code --- apiserver/plane/api/urls.py | 7 ------- apiserver/plane/api/views/__init__.py | 1 - apiserver/plane/api/views/issue.py | 13 ------------- 3 files changed, 21 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 3a7e02f62..96244a367 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -37,7 +37,6 @@ from plane.api.views import ( CycleViewSet, FileAssetEndpoint, IssueViewSet, - UserIssuesEndpoint, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, @@ -145,12 +144,6 @@ urlpatterns = [ UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), name="user-project-invitaions", ), - # user issues - path( - "users/me/issues/", - UserIssuesEndpoint.as_view(), - name="user-issues", - ), ## Workspaces ## path( "workspaces/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c2efaa707..a4d9021c6 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -41,7 +41,6 @@ from .cycle import CycleViewSet, CycleIssueViewSet, BulkAssignIssuesToCycleEndpo from .asset import FileAssetEndpoint from .issue import ( IssueViewSet, - UserIssuesEndpoint, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 530685a0f..9a7d69c83 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -160,19 +160,6 @@ class IssueViewSet(BaseViewSet): ) -class UserIssuesEndpoint(BaseAPIView): - def get(self, request): - try: - issues = Issue.objects.filter(assignees__in=[request.user]) - serializer = IssueSerializer(issues, 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_500_INTERNAL_SERVER_ERROR, - ) - class UserWorkSpaceIssues(BaseAPIView): def get(self, request, slug): try: From 668c6c2d2dd8a49411e3574b2a3f60d0e669ecf9 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:47:32 +0530 Subject: [PATCH 034/104] fix: project name uniqueness in workspace --- apiserver/plane/db/models/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 02fea2c7c..b461bb837 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -59,7 +59,7 @@ class Project(BaseModel): return f"{self.name} <{self.workspace.name}>" class Meta: - unique_together = ["identifier", "workspace"] + unique_together = [["identifier", "workspace"], ["name", "workspace"]] verbose_name = "Project" verbose_name_plural = "Projects" db_table = "project" From 3b0b88620afc8a651623bf9e51b01cbe254280f9 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:48:09 +0530 Subject: [PATCH 035/104] doc: todo comments --- apiserver/plane/db/models/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index b461bb837..1bed4fc8f 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -130,7 +130,7 @@ class ProjectMember(ProjectBaseModel): """Return members of the project""" return f"{self.member.email} <{self.project.name}>" - +# TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): workspace = models.ForeignKey( From 2881f990f56896fb48c97556d0bf3108a5aa7874 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:48:55 +0530 Subject: [PATCH 036/104] dev: added db migrations for identifiers --- .../db/migrations/0010_auto_20221213_2348.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apiserver/plane/db/migrations/0010_auto_20221213_2348.py diff --git a/apiserver/plane/db/migrations/0010_auto_20221213_2348.py b/apiserver/plane/db/migrations/0010_auto_20221213_2348.py new file mode 100644 index 000000000..bd33de299 --- /dev/null +++ b/apiserver/plane/db/migrations/0010_auto_20221213_2348.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.14 on 2022-12-13 18:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0009_auto_20221213_2328'), + ] + + operations = [ + migrations.AddField( + model_name='projectidentifier', + name='workspace', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_identifiers', to='db.workspace'), + ), + migrations.AlterField( + model_name='project', + name='identifier', + field=models.CharField(max_length=5, verbose_name='Project Identifier'), + ), + migrations.AlterUniqueTogether( + name='project', + unique_together={('name', 'workspace'), ('identifier', 'workspace')}, + ), + migrations.AlterUniqueTogether( + name='projectidentifier', + unique_together={('name', 'workspace')}, + ), + ] From cefea57bd6c4436a0fc65dcb6d58c7173f3dc2b1 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:50:14 +0530 Subject: [PATCH 037/104] fix: url missing for my workspace issues --- apiserver/plane/api/urls.py | 4 ++-- apiserver/plane/api/views/issue.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 96244a367..149dc3329 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -54,8 +54,8 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, - UserWorkSpaceIssues, UserLastProjectWithWorkspaceEndpoint, + UserWorkSpaceIssues, ) from plane.api.views.project import AddTeamToProjectEndpoint @@ -496,7 +496,7 @@ urlpatterns = [ BulkDeleteIssuesEndpoint.as_view(), ), path( - "workspaces//me/issues/", + "workspaces//my-issues/", UserWorkSpaceIssues.as_view(), name="workspace-issues", ), diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 9a7d69c83..9b13dae4c 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -163,7 +163,6 @@ class IssueViewSet(BaseViewSet): class UserWorkSpaceIssues(BaseAPIView): def get(self, request, slug): try: - print(request.user) issues = Issue.objects.filter( assignees__in=[request.user], workspace__slug=slug ) From b6ec965f62dbf6bbb44d5b57390d7b1bd3152b79 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 23:55:37 +0530 Subject: [PATCH 038/104] build: update test runner requirements path --- .github/workflows/test_runner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml index 5f22fc80f..374888b73 100644 --- a/.github/workflows/test_runner.yml +++ b/.github/workflows/test_runner.yml @@ -44,6 +44,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements/test.txt + pip install -r apiserver/requirements/test.txt - name: Run Tests run: coverage run --source='.' manage.py test --settings=plane.settings.test From 3e6289a288f420c5f1f24b51d2ed7e2e9bb0ce11 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 14 Dec 2022 00:00:07 +0530 Subject: [PATCH 039/104] build: update working directory in test_runner --- .github/workflows/test_runner.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml index 374888b73..8013a7b5d 100644 --- a/.github/workflows/test_runner.yml +++ b/.github/workflows/test_runner.yml @@ -42,8 +42,10 @@ jobs: - name: psycopg2 prerequisites run: sudo apt-get install libpq-dev - name: Install dependencies + working-directory: ./apiserver run: | python -m pip install --upgrade pip - pip install -r apiserver/requirements/test.txt + pip install -r requirements/test.txt - name: Run Tests + working-directory: ./apiserver run: coverage run --source='.' manage.py test --settings=plane.settings.test From 3d0450580ce84e9375c3fbc2e3df94d799dca5ea Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 14 Dec 2022 00:03:19 +0530 Subject: [PATCH 040/104] build: update secret key env in test_runner --- .github/workflows/test_runner.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml index 8013a7b5d..1906d96d6 100644 --- a/.github/workflows/test_runner.yml +++ b/.github/workflows/test_runner.yml @@ -48,4 +48,6 @@ jobs: pip install -r requirements/test.txt - name: Run Tests working-directory: ./apiserver + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} run: coverage run --source='.' manage.py test --settings=plane.settings.test From b51c31f8d482c21eee2131839d74e13496911512 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 14 Dec 2022 00:07:59 +0530 Subject: [PATCH 041/104] build: update workflow file --- .github/workflows/test_runner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml index 1906d96d6..5780c9f63 100644 --- a/.github/workflows/test_runner.yml +++ b/.github/workflows/test_runner.yml @@ -49,5 +49,5 @@ jobs: - name: Run Tests working-directory: ./apiserver env: - SECRET_KEY: ${{ secrets.SECRET_KEY }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} run: coverage run --source='.' manage.py test --settings=plane.settings.test From 6638f132353fe5c89886cc5e748ba4a3ecca6731 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 14 Dec 2022 19:30:00 +0530 Subject: [PATCH 042/104] build: update app.json to add environment variables --- app.json | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/app.json b/app.json index 884412fa6..34941e9ab 100644 --- a/app.json +++ b/app.json @@ -8,9 +8,62 @@ "stack": "container", "keywords": ["plane", "project management", "django", "next"], "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-python.git" + "env": { + "EMAIL_HOST": { + "description": "Email host to send emails from", + "value": "" + }, + "EMAIL_HOST_USER": { + "description" : "Email host to send emails from", + "value": "" + }, + "EMAIL_HOST_PASSWORD": { + "description": "Email host to send emails from", + "value": "" + }, + "AWS_REGION": { + "description" : "AWS Region to use for S3", + "value": "false" + }, + "AWS_ACCESS_KEY_ID": { + "description": "AWS Access Key ID to use for S3", + "value": "" + }, + "AWS_SECRET_ACCESS_KEY": { + "description": "AWS Secret Access Key to use for S3", + "value": "" + }, + "SENTRY_DSN": { + "description": "", + "value": "" + }, + "AWS_S3_BUCKET_NAME": { + "description": "AWS Bucket Name to use for S3", + "value": "" + }, + "WEB_URL": { + "description": "Web URL for Plane", + "value": "" + }, + "GITHUB_CLIENT_SECRET": { + "description": "Github Client Secret", + "value": "" + }, + "NEXT_PUBLIC_GITHUB_ID": { + "description": "Next Public Github ID", + "value": "" + }, + "NEXT_PUBLIC_GOOGLE_CLIENTID": { + "description": "Next Public Google Client ID", + "value": "" + }, + "NEXT_PUBLIC_API_BASE_URL": { + "description": "Next Public API Base URL", + "value": "" + }, + "SECRET_KEY": { + "description": "Django Secret Key", + "value": "" } - ] -} + } +} \ No newline at end of file From 53c3aec8b225c3836983aa7e630e27eb8f85bd82 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Wed, 14 Dec 2022 22:46:41 +0530 Subject: [PATCH 043/104] style: made each state group capsule --- .../state/create-update-state-inline.tsx | 184 +++++++++++ .../project/settings/StatesSettings.tsx | 291 ++++-------------- 2 files changed, 247 insertions(+), 228 deletions(-) create mode 100644 apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx diff --git a/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx new file mode 100644 index 000000000..84312a404 --- /dev/null +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx @@ -0,0 +1,184 @@ +import React, { useEffect } from "react"; +// swr +import { mutate } from "swr"; +// react hook form +import { useForm, Controller } from "react-hook-form"; +// react color +import { TwitterPicker } from "react-color"; +// headless +import { Popover, Transition } from "@headlessui/react"; +// constants +import { STATE_LIST } from "constants/fetch-keys"; +// services +import stateService from "lib/services/state.service"; +// ui +import { Button, Input } from "ui"; +// types +import type { IState } from "types"; + +type Props = { + workspaceSlug?: string; + projectId?: string; + data: IState | null; + onClose: () => void; + selectedGroup: StateGroup | null; +}; + +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; + +export const CreateUpdateStateInline: React.FC = ({ + workspaceSlug, + projectId, + data, + onClose, + selectedGroup, +}) => { + const { + register, + handleSubmit, + formState: { errors }, + setError, + watch, + reset, + control, + } = useForm({ + defaultValues: { + name: "", + color: "#000000", + group: "backlog", + }, + }); + + const handleClose = () => { + onClose(); + reset({ name: "", color: "#000000", group: "backlog" }); + }; + + const onSubmit = async (formData: IState) => { + if (!workspaceSlug || !projectId) return; + const payload: IState = { + ...formData, + }; + if (!data) { + await stateService + .createState(workspaceSlug, projectId, { ...payload, group: selectedGroup }) + .then((res) => { + mutate(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } else { + await stateService + .updateState(workspaceSlug, projectId, data.id, { + ...payload, + group: selectedGroup ?? "backlog", + }) + .then((res) => { + mutate( + STATE_LIST(projectId), + (prevData) => { + const newData = prevData?.map((item) => { + if (item.id === res.id) { + return res; + } + return item; + }); + return newData; + }, + false + ); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } + }; + + useEffect(() => { + if (data === null) return; + reset(data); + }, [data]); + + return ( +
+
+ + {({ open }) => ( + <> + + {watch("color") && watch("color") !== "" && ( + + )} + + + + + ( + onChange(value.hex)} /> + )} + /> + + + + )} + +
+ + + + +
+ ); +}; diff --git a/apps/app/components/project/settings/StatesSettings.tsx b/apps/app/components/project/settings/StatesSettings.tsx index f793046d4..cfb642c69 100644 --- a/apps/app/components/project/settings/StatesSettings.tsx +++ b/apps/app/components/project/settings/StatesSettings.tsx @@ -1,22 +1,12 @@ -import React, { useEffect, useState } from "react"; -// swr -import { mutate } from "swr"; -// react hook form -import { useForm, Controller } from "react-hook-form"; -// react color -import { TwitterPicker } from "react-color"; -// headless -import { Popover, Transition } from "@headlessui/react"; +import React, { useState } from "react"; // hooks import useUser from "lib/hooks/useUser"; -// constants -import { STATE_LIST } from "constants/fetch-keys"; -// services -import stateService from "lib/services/state.service"; // components +import { + StateGroup, + CreateUpdateStateInline, +} from "components/project/issues/BoardView/state/create-update-state-inline"; import ConfirmStateDeletion from "components/project/issues/BoardView/state/confirm-state-delete"; -// ui -import { Button, Input } from "ui"; // icons import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; // constants @@ -28,173 +18,6 @@ type Props = { projectId: string | string[] | undefined; }; -type CreateUpdateStateProps = { - workspaceSlug?: string; - projectId?: string; - data: IState | null; - onClose: () => void; - selectedGroup: StateGroup | null; -}; - -type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; - -const CreateUpdateState: React.FC = ({ - workspaceSlug, - projectId, - data, - onClose, - selectedGroup, -}) => { - const { - register, - handleSubmit, - formState: { errors }, - setError, - watch, - reset, - control, - } = useForm({ - defaultValues: { - name: "", - color: "#000000", - group: "backlog", - }, - }); - - const handleClose = () => { - onClose(); - reset({ name: "", color: "#000000", group: "backlog" }); - }; - - const onSubmit = async (formData: IState) => { - if (!workspaceSlug || !projectId) return; - const payload: IState = { - ...formData, - }; - if (!data) { - await stateService - .createState(workspaceSlug, projectId, { ...payload, group: selectedGroup }) - .then((res) => { - mutate(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false); - handleClose(); - }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof IState, { - message: err[key].join(", "), - }); - }); - }); - } else { - await stateService - .updateState(workspaceSlug, projectId, data.id, { - ...payload, - group: selectedGroup ?? "backlog", - }) - .then((res) => { - mutate( - STATE_LIST(projectId), - (prevData) => { - const newData = prevData?.map((item) => { - if (item.id === res.id) { - return res; - } - return item; - }); - return newData; - }, - false - ); - handleClose(); - }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof IState, { - message: err[key].join(", "), - }); - }); - }); - } - }; - - useEffect(() => { - if (data === null) return; - reset(data); - }, [data]); - - return ( -
-
- - {({ open }) => ( - <> - - {watch("color") && watch("color") !== "" && ( - - )} - - - - - ( - onChange(value.hex)} /> - )} - /> - - - - )} - -
- - - - -
- ); -}; - const StatesSettings: React.FC = ({ projectId }) => { const [activeGroup, setActiveGroup] = useState(null); const [selectedState, setSelectedState] = useState(null); @@ -221,64 +44,76 @@ const StatesSettings: React.FC = ({ projectId }) => {
{Object.keys(groupedStates).map((key) => ( -
-
+ +

{key} states

-
- {groupedStates[key]?.map((state) => - state.id !== selectedState ? ( -
-
+
+
+ {groupedStates[key]?.map((state) => + state.id !== selectedState ? (
-

{addSpaceIfCamelCase(state.name)}

-
-
- - -
-
- ) : ( - +
+
+

{addSpaceIfCamelCase(state.name)}

+
+
+ + +
+
+ ) : ( +
+ { + setActiveGroup(null); + setSelectedState(null); + }} + workspaceSlug={activeWorkspace?.slug} + data={states?.find((state) => state.id === selectedState) ?? null} + selectedGroup={key as keyof StateGroup} + /> +
+ ) + )} +
+ {key === activeGroup && ( + { setActiveGroup(null); setSelectedState(null); }} workspaceSlug={activeWorkspace?.slug} - data={states?.find((state) => state.id === selectedState) ?? null} + data={null} selectedGroup={key as keyof StateGroup} /> - ) - )} - {key === activeGroup && ( - { - setActiveGroup(null); - setSelectedState(null); - }} - workspaceSlug={activeWorkspace?.slug} - data={null} - selectedGroup={key as keyof StateGroup} - /> - )} -
+ )} +
+ ))}
From f9b8ee0d500f84990704c322937d1b5fe6d07bef Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 15 Dec 2022 02:37:26 +0530 Subject: [PATCH 044/104] build: update to a single docker file for one click deploys --- Dockerfile | 138 +++++++++++++++++++++++++++++++++ supervisor/service_script.conf | 33 ++++++++ 2 files changed, 171 insertions(+) create mode 100644 Dockerfile create mode 100644 supervisor/service_script.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b9d448a3b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,138 @@ +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +RUN apk add curl + +COPY ./apps ./apps +COPY ./package.json ./package.json +COPY ./.eslintrc.json ./.eslintrc.json +COPY ./yarn.lock ./yarn.lock + +RUN yarn global add turbo +RUN turbo prune --scope=app --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM node:18-alpine AS installer + +RUN apk add --no-cache libc6-compat +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +RUN yarn turbo run build --filter=app... + +FROM python:3.8.14-alpine3.16 AS runner + +ENV SECRET_KEY ${SECRET_KEY} +ENV DATABASE_URL ${DATABASE_URL} +ENV REDIS_URL ${REDIS_URL} +ENV EMAIL_HOST ${EMAIL_HOST} +ENV EMAIL_HOST_USER ${EMAIL_HOST_USER} +ENV EMAIL_HOST_PASSWORD ${EMAIL_HOST_PASSWORD} + +ENV AWS_REGION ${AWS_REGION} +ENV AWS_ACCESS_KEY_ID ${AWS_ACCESS_KEY_ID} +ENV AWS_SECRET_ACCESS_KEY ${AWS_SECRET_ACCESS_KEY} +ENV AWS_S3_BUCKET_NAME ${AWS_S3_BUCKET_NAME} + + +ENV SENTRY_DSN ${SENTRY_DSN} +ENV WEB_URL ${WEB_URL} + +ENV DISABLE_COLLECTSTATIC ${DISABLE_COLLECTSTATIC} + +ENV GITHUB_CLIENT_SECRET ${GITHUB_CLIENT_SECRET} +ENV NEXT_PUBLIC_GITHUB_ID ${NEXT_PUBLIC_GITHUB_ID} +ENV NEXT_PUBLIC_GOOGLE_CLIENTID ${NEXT_PUBLIC_GOOGLE_CLIENTID} +ENV NEXT_PUBLIC_API_BASE_URL ${NEXT_PUBLIC_API_BASE_URL} + +# Frontend + +RUN apk --update --no-cache add \ + "libpq~=14" \ + "libxslt~=1.1" \ + "nodejs-current~=18" \ + "xmlsec~=1.2" + +WORKDIR /app + +# Don't run production as root +RUN addgroup -S plane && \ + adduser -S captain -G plane + +USER captain + +COPY --from=installer /app/apps/app/next.config.js . +COPY --from=installer /app/apps/app/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ +COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static + +EXPOSE 3000 + +# Backend + +USER root + + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + + +COPY ./apiserver/requirements.txt ./ +COPY ./apiserver/requirements ./requirements +RUN apk add libffi-dev +RUN apk --update --no-cache --virtual .build-deps add \ + "bash~=5.1" \ + "g++~=11.2" \ + "gcc~=11.2" \ + "cargo~=1.60" \ + "git~=2" \ + "make~=4.3" \ + "postgresql13-dev~=13" \ + "libc-dev" \ + "linux-headers" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps + + +RUN chown captain.plane /app + +# Add in Django deps and generate Django's static files +COPY ./apiserver/manage.py manage.py +COPY ./apiserver/plane plane/ +COPY ./apiserver/templates templates/ + +COPY ./apiserver/gunicorn.config.py ./ +USER root +RUN apk --update --no-cache add "bash~=5.1" +COPY ./bin ./bin/ +USER captain + +# Expose container port and run entry point script +EXPOSE 8000 + +RUN python manage.py migrate + +RUN apk --update add supervisor + +ADD /supervisor /src/supervisor + +CMD ["supervisord","-c","/src/supervisor/service_script.conf"] \ No newline at end of file diff --git a/supervisor/service_script.conf b/supervisor/service_script.conf new file mode 100644 index 000000000..a635b4907 --- /dev/null +++ b/supervisor/service_script.conf @@ -0,0 +1,33 @@ +[supervisord] +nodaemon=true + +[program:frontend] +directory=/app +command= node apps/app/server.js +autostart=true +autorestart=true +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes = 0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes = 0 + + +[program:backend] +directory=/app +command= gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +autostart=true +autorestart=true +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes = 0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes = 0 + +[program:worker] +directory=/app +command= python manage.py rqworker +autostart=true +autorestart=true +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes = 0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes = 0 \ No newline at end of file From c7c2c19d5b7effac27b22d536ef40cd8fca21445 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 15 Dec 2022 02:55:03 +0530 Subject: [PATCH 045/104] build: remove migration run from Dockerfile --- Dockerfile | 1 - supervisor/service_script.conf | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9d448a3b..efdcfbe68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -129,7 +129,6 @@ USER captain # Expose container port and run entry point script EXPOSE 8000 -RUN python manage.py migrate RUN apk --update add supervisor diff --git a/supervisor/service_script.conf b/supervisor/service_script.conf index a635b4907..d8816f048 100644 --- a/supervisor/service_script.conf +++ b/supervisor/service_script.conf @@ -14,7 +14,7 @@ stdout_logfile_maxbytes = 0 [program:backend] directory=/app -command= gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +command= ./bin/takeoff autostart=true autorestart=true stderr_logfile=/dev/stdout @@ -24,7 +24,7 @@ stdout_logfile_maxbytes = 0 [program:worker] directory=/app -command= python manage.py rqworker +command= ./bin/worker autostart=true autorestart=true stderr_logfile=/dev/stdout From 2fa8b68b7aa92f4a394e1f52178dde8ec70ebb08 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 15 Dec 2022 02:57:12 +0530 Subject: [PATCH 046/104] build: Update docker file to install supervisord --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index efdcfbe68..abb8505cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -129,6 +129,7 @@ USER captain # Expose container port and run entry point script EXPOSE 8000 +USER root RUN apk --update add supervisor From 0cda15408d9bb28826d43c7be4f39f24d8fab019 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 15 Dec 2022 09:47:56 +0530 Subject: [PATCH 047/104] fix: bug fixes in cycles --- .../project/cycles/BoardView/single-board.tsx | 662 ---------------- .../project/cycles/ListView/index.tsx | 714 ------------------ .../{BoardView => board-view}/index.tsx | 8 +- .../cycles/board-view/single-board.tsx | 377 +++++++++ .../project/cycles/list-view/index.tsx | 352 +++++++++ .../project/cycles/stats-view/index.tsx | 23 + .../project/cycles/stats-view/single-stat.tsx | 102 +++ .../project/issues/IssuesListModal.tsx | 5 +- .../index.tsx} | 407 ++++------ .../issue-detail-sidebar/select-assignee.tsx | 181 +++++ .../issue-detail-sidebar/select-cycle.tsx | 98 +++ .../issue-detail-sidebar/select-parent.tsx | 74 ++ .../issue-detail-sidebar/select-priority.tsx | 84 +++ .../issue-detail-sidebar/select-state.tsx | 116 +++ .../components/sidebar/workspace-options.tsx | 189 +++-- apps/app/layouts/app-layout.tsx | 4 +- apps/app/layouts/types.d.ts | 2 + apps/app/pages/me/my-issues.tsx | 643 +++++++++++++--- .../projects/[projectId]/cycles/[cycleId].tsx | 386 +++++----- .../projects/[projectId]/cycles/index.tsx | 9 +- .../projects/[projectId]/issues/[issueId].tsx | 8 +- .../pages/projects/[projectId]/settings.tsx | 7 +- apps/app/pages/workspace/index.tsx | 151 ++-- 23 files changed, 2550 insertions(+), 2052 deletions(-) delete mode 100644 apps/app/components/project/cycles/BoardView/single-board.tsx delete mode 100644 apps/app/components/project/cycles/ListView/index.tsx rename apps/app/components/project/cycles/{BoardView => board-view}/index.tsx (91%) create mode 100644 apps/app/components/project/cycles/board-view/single-board.tsx create mode 100644 apps/app/components/project/cycles/list-view/index.tsx create mode 100644 apps/app/components/project/cycles/stats-view/index.tsx create mode 100644 apps/app/components/project/cycles/stats-view/single-stat.tsx rename apps/app/components/project/issues/issue-detail/{IssueDetailSidebar.tsx => issue-detail-sidebar/index.tsx} (55%) create mode 100644 apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx create mode 100644 apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx create mode 100644 apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx create mode 100644 apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx create mode 100644 apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx diff --git a/apps/app/components/project/cycles/BoardView/single-board.tsx b/apps/app/components/project/cycles/BoardView/single-board.tsx deleted file mode 100644 index 92ff37110..000000000 --- a/apps/app/components/project/cycles/BoardView/single-board.tsx +++ /dev/null @@ -1,662 +0,0 @@ -// react -import React, { useState } from "react"; -// next -import Link from "next/link"; -import Image from "next/image"; -// swr -import useSWR from "swr"; -// services -import cycleServices from "lib/services/cycles.service"; -// hooks -import useUser from "lib/hooks/useUser"; -// ui -import { Spinner } from "ui"; -// icons -import { - ArrowsPointingInIcon, - ArrowsPointingOutIcon, - CalendarDaysIcon, - PlusIcon, - EllipsisHorizontalIcon, - TrashIcon, -} from "@heroicons/react/24/outline"; -import User from "public/user.png"; -// types -import { - CycleIssueResponse, - ICycle, - IIssue, - IWorkspaceMember, - NestedKeyOf, - Properties, -} from "types"; -// constants -import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; -import { - addSpaceIfCamelCase, - findHowManyDaysLeft, - renderShortNumericDateFormat, -} from "constants/common"; -import { Menu, Transition } from "@headlessui/react"; -import workspaceService from "lib/services/workspace.service"; - -type Props = { - properties: Properties; - groupedByIssues: { - [key: string]: IIssue[]; - }; - selectedGroup: NestedKeyOf | null; - groupTitle: string; - createdBy: string | null; - bgColor?: string; - openCreateIssueModal: ( - sprintId: string, - issue?: IIssue, - actionType?: "create" | "edit" | "delete" - ) => void; - openIssuesListModal: (cycleId: string) => void; - removeIssueFromCycle: (cycleId: string, bridgeId: string) => void; -}; - -const SingleCycleBoard: React.FC = ({ - properties, - groupedByIssues, - selectedGroup, - groupTitle, - createdBy, - bgColor, - openCreateIssueModal, - openIssuesListModal, - removeIssueFromCycle, -}) => { - // Collapse/Expand - const [show, setState] = useState(true); - - const { activeWorkspace, activeProject } = useUser(); - - if (selectedGroup === "priority") - groupTitle === "high" - ? (bgColor = "#dc2626") - : groupTitle === "medium" - ? (bgColor = "#f97316") - : groupTitle === "low" - ? (bgColor = "#22c55e") - : (bgColor = "#ff0000"); - - const { data: people } = useSWR( - activeWorkspace ? WORKSPACE_MEMBERS : null, - activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null - ); - - return ( -
-
-
-
-
- -

- {groupTitle === null || groupTitle === "null" - ? "None" - : createdBy - ? createdBy - : addSpaceIfCamelCase(groupTitle)} -

- - {groupedByIssues[groupTitle].length} - -
-
-
-
- {groupedByIssues[groupTitle].map((childIssue, index: number) => { - const assignees = [ - ...(childIssue?.assignees_list ?? []), - ...(childIssue?.assignees ?? []), - ]?.map((assignee) => { - const tempPerson = people?.find((p) => p.member.id === assignee)?.member; - - return { - avatar: tempPerson?.avatar, - first_name: tempPerson?.first_name, - email: tempPerson?.email, - }; - }); - - return ( -
-
-
- -
- - - {properties.key && ( -
- {activeProject?.identifier}-{childIssue.sequence_id} -
- )} -
- {childIssue.name} -
-
- -
- {properties.priority && ( -
- {/* {getPriorityIcon(childIssue.priority ?? "")} */} - {childIssue.priority ?? "None"} -
-
Priority
-
- {childIssue.priority ?? "None"} -
-
-
- )} - {properties.state && ( -
- - {addSpaceIfCamelCase(childIssue.state_detail.name)} -
-
State
-
{childIssue.state_detail.name}
-
-
- )} - {properties.start_date && ( -
- - {childIssue.start_date - ? renderShortNumericDateFormat(childIssue.start_date) - : "N/A"} -
-
Started at
-
{renderShortNumericDateFormat(childIssue.start_date ?? "")}
-
-
- )} - {properties.target_date && ( -
- - {childIssue.target_date - ? renderShortNumericDateFormat(childIssue.target_date) - : "N/A"} -
-
Target date
-
{renderShortNumericDateFormat(childIssue.target_date ?? "")}
-
- {childIssue.target_date && - (childIssue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : findHowManyDaysLeft(childIssue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : "Target date")} -
-
-
- )} - {properties.assignee && ( -
- {childIssue.assignee_details?.length > 0 ? ( - childIssue.assignee_details?.map((assignee, index: number) => ( -
- {assignee.avatar && assignee.avatar !== "" ? ( -
- {assignee.name} -
- ) : ( -
- {assignee.first_name.charAt(0)} -
- )} -
- )) - ) : ( -
- No user -
- )} -
-
Assigned to
-
- {childIssue.assignee_details?.length > 0 - ? childIssue.assignee_details - .map((assignee) => assignee.first_name) - .join(", ") - : "No one"} -
-
-
- )} -
-
-
- ); - })} - -
-
-
- ); - - // return ( - //
- //
- //
- //
- //
- //

- // {cycle.name} - //

- // {cycleIssues?.length} - //
- //
- - //
- // - // - // - // - // - - // - // - //
- // - // {(active) => ( - // - // )} - // - // - // {(active) => ( - // - // )} - // - //
- //
- //
- //
- //
- //
- //
- // {cycleIssues ? ( - // cycleIssues.map((issue, index: number) => ( - //
- //
- //
- // - // - // - // - // - // - //
- // - //
- //
- // - //
- // - //
- //
- //
- //
- //
- // - // - // {properties.key && ( - //
- // {activeProject?.identifier}-{childIssue.sequence_id} - //
- // )} - //
- // {childIssue.name} - //
- //
- // - //
- // {properties.priority && ( - //
- // {/* {getPriorityIcon(childIssue.priority ?? "")} */} - // {childIssue.priority ?? "None"} - //
- //
Priority
- //
- // {childIssue.priority ?? "None"} - //
- //
- //
- // )} - // {properties.state && ( - //
- // - // {addSpaceIfCamelCase(childIssue.state_detail.name)} - //
- //
State
- //
{childIssue.state_detail.name}
- //
- //
- // )} - // {properties.start_date && ( - //
- // - // {childIssue.start_date - // ? renderShortNumericDateFormat(childIssue.start_date) - // : "N/A"} - //
- //
Started at
- //
- // {renderShortNumericDateFormat(childIssue.start_date ?? "")} - //
- //
- //
- // )} - // {properties.target_date && ( - //
- // - // {childIssue.target_date - // ? renderShortNumericDateFormat(childIssue.target_date) - // : "N/A"} - //
- //
Target date
- //
- // {renderShortNumericDateFormat(childIssue.target_date ?? "")} - //
- //
- // {childIssue.target_date && - // (childIssue.target_date < new Date().toISOString() - // ? `Target date has passed by ${findHowManyDaysLeft( - // childIssue.target_date - // )} days` - // : findHowManyDaysLeft(childIssue.target_date) <= 3 - // ? `Target date is in ${findHowManyDaysLeft( - // childIssue.target_date - // )} days` - // : "Target date")} - //
- //
- //
- // )} - // {properties.assignee && ( - //
- // {childIssue.assignee_details?.length > 0 ? ( - // childIssue.assignee_details?.map((assignee, index: number) => ( - //
- // {assignee.avatar && assignee.avatar !== "" ? ( - //
- // {assignee.name} - //
- // ) : ( - //
- // {assignee.first_name.charAt(0)} - //
- // )} - //
- // )) - // ) : ( - //
- // No user - //
- // )} - //
- //
Assigned to
- //
- // {childIssue.assignee_details?.length > 0 - // ? childIssue.assignee_details - // .map((assignee) => assignee.first_name) - // .join(", ") - // : "No one"} - //
- //
- //
- // )} - //
- //
- //
- // )) - // ) : ( - //
- // - //
- // )} - // - //
- //
- //
- // ); -}; - -export default SingleCycleBoard; diff --git a/apps/app/components/project/cycles/ListView/index.tsx b/apps/app/components/project/cycles/ListView/index.tsx deleted file mode 100644 index 5add9687a..000000000 --- a/apps/app/components/project/cycles/ListView/index.tsx +++ /dev/null @@ -1,714 +0,0 @@ -// react -import React from "react"; -// next -import Link from "next/link"; -// swr -import useSWR from "swr"; -// headless ui -import { Disclosure, Transition, Menu } from "@headlessui/react"; -// services -import cycleServices from "lib/services/cycles.service"; -// hooks -import useUser from "lib/hooks/useUser"; -// ui -import { Spinner } from "ui"; -// icons -import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; -import { CalendarDaysIcon } from "@heroicons/react/24/outline"; -// types -import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types"; -// fetch keys -import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; -// constants -import { - addSpaceIfCamelCase, - findHowManyDaysLeft, - renderShortNumericDateFormat, -} from "constants/common"; -import workspaceService from "lib/services/workspace.service"; - -type Props = { - groupedByIssues: { - [key: string]: IIssue[]; - }; - properties: Properties; - selectedGroup: NestedKeyOf | null; - openCreateIssueModal: ( - sprintId: string, - issue?: IIssue, - actionType?: "create" | "edit" | "delete" - ) => void; - openIssuesListModal: (cycleId: string) => void; - removeIssueFromCycle: (cycleId: string, bridgeId: string) => void; -}; - -const CyclesListView: React.FC = ({ - groupedByIssues, - selectedGroup, - openCreateIssueModal, - openIssuesListModal, - properties, - removeIssueFromCycle, -}) => { - const { activeWorkspace, activeProject } = useUser(); - - const { data: people } = useSWR( - activeWorkspace ? WORKSPACE_MEMBERS : null, - activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null - ); - - return ( -
- {Object.keys(groupedByIssues).map((singleGroup) => ( - - {({ open }) => ( -
-
- -
- - - - {selectedGroup !== null ? ( -

- {singleGroup === null || singleGroup === "null" - ? selectedGroup === "priority" && "No priority" - : addSpaceIfCamelCase(singleGroup)} -

- ) : ( -

All Issues

- )} -

- {groupedByIssues[singleGroup as keyof IIssue].length} -

-
-
-
- - -
- {groupedByIssues[singleGroup] ? ( - groupedByIssues[singleGroup].length > 0 ? ( - groupedByIssues[singleGroup].map((issue: IIssue) => { - const assignees = [ - ...(issue?.assignees_list ?? []), - ...(issue?.assignees ?? []), - ]?.map((assignee) => { - const tempPerson = people?.find( - (p) => p.member.id === assignee - )?.member; - - return { - avatar: tempPerson?.avatar, - first_name: tempPerson?.first_name, - email: tempPerson?.email, - }; - }); - - return ( -
- -
- {properties.priority && ( -
- {/* {getPriorityIcon(issue.priority ?? "")} */} - {issue.priority ?? "None"} -
-
Priority
-
- {issue.priority ?? "None"} -
-
-
- )} - {properties.state && ( -
- - {addSpaceIfCamelCase(issue?.state_detail.name)} -
-
State
-
{issue?.state_detail.name}
-
-
- )} - {properties.start_date && ( -
- - {issue.start_date - ? renderShortNumericDateFormat(issue.start_date) - : "N/A"} -
-
Started at
-
- {renderShortNumericDateFormat(issue.start_date ?? "")} -
-
-
- )} - {properties.target_date && ( -
- - {issue.target_date - ? renderShortNumericDateFormat(issue.target_date) - : "N/A"} -
-
- Target date -
-
- {renderShortNumericDateFormat(issue.target_date ?? "")} -
-
- {issue.target_date && - (issue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - issue.target_date - )} days` - : findHowManyDaysLeft(issue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - issue.target_date - )} days` - : "Target date")} -
-
-
- )} - - - - - - - - - -
- -
-
- -
- -
-
-
-
-
-
- ); - }) - ) : ( -

No issues.

- ) - ) : ( -
- -
- )} -
-
-
-
- -
-
- )} -
- ))} -
- ); - - // return ( - // <> - // - // {({ open }) => ( - //
- //
- // - - // - // - // - // - // - // - // - // - // - // - // - // - // - //
- // - // - // - // {(provided) => ( - //
- // {cycleIssues ? ( - // cycleIssues.length > 0 ? ( - // cycleIssues.map((issue, index) => ( - // - // {(provided, snapshot) => ( - //
- // - //
- // {properties.priority && ( - //
- // {/* {getPriorityIcon(issue.priority ?? "")} */} - // {issue.priority ?? "None"} - //
- //
- // Priority - //
- //
- // {issue.priority ?? "None"} - //
- //
- //
- // )} - // {properties.state && ( - //
- // - // {addSpaceIfCamelCase( - // issue?.state_detail.name - // )} - //
- //
State
- //
{issue?.state_detail.name}
- //
- //
- // )} - // {properties.start_date && ( - //
- // - // {issue.start_date - // ? renderShortNumericDateFormat( - // issue.start_date - // ) - // : "N/A"} - //
- //
Started at
- //
- // {renderShortNumericDateFormat( - // issue.start_date ?? "" - // )} - //
- //
- //
- // )} - // {properties.target_date && ( - //
- // - // {issue.target_date - // ? renderShortNumericDateFormat( - // issue.target_date - // ) - // : "N/A"} - //
- //
- // Target date - //
- //
- // {renderShortNumericDateFormat( - // issue.target_date ?? "" - // )} - //
- //
- // {issue.target_date && - // (issue.target_date < - // new Date().toISOString() - // ? `Target date has passed by ${findHowManyDaysLeft( - // issue.target_date - // )} days` - // : findHowManyDaysLeft( - // issue.target_date - // ) <= 3 - // ? `Target date is in ${findHowManyDaysLeft( - // issue.target_date - // )} days` - // : "Target date")} - //
- //
- //
- // )} - // - // - // - // - // - // - // - // - // - //
- // - //
- //
- // - //
- // - //
- //
- //
- //
- //
- //
- // )} - //
- // )) - // ) : ( - //

- // This cycle has no issue. - //

- // ) - // ) : ( - //
- // - //
- // )} - // {provided.placeholder} - //
- // )} - //
- //
- //
- //
- // - // - // - // Add issue - // - - // - // - //
- // - // {(active) => ( - // - // )} - // - // - // {(active) => ( - // - // )} - // - //
- //
- //
- //
- //
- //
- // )} - //
- // - // ); -}; - -export default CyclesListView; diff --git a/apps/app/components/project/cycles/BoardView/index.tsx b/apps/app/components/project/cycles/board-view/index.tsx similarity index 91% rename from apps/app/components/project/cycles/BoardView/index.tsx rename to apps/app/components/project/cycles/board-view/index.tsx index 8026786c4..07d68f20d 100644 --- a/apps/app/components/project/cycles/BoardView/index.tsx +++ b/apps/app/components/project/cycles/board-view/index.tsx @@ -1,5 +1,5 @@ // components -import SingleBoard from "components/project/cycles/BoardView/single-board"; +import SingleBoard from "components/project/cycles/board-view/single-board"; // ui import { Spinner } from "ui"; // types @@ -13,11 +13,7 @@ type Props = { properties: Properties; selectedGroup: NestedKeyOf | null; members: IProjectMember[] | undefined; - openCreateIssueModal: ( - sprintId: string, - issue?: IIssue, - actionType?: "create" | "edit" | "delete" - ) => void; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openIssuesListModal: (cycleId: string) => void; removeIssueFromCycle: (cycleId: string, bridgeId: string) => void; }; diff --git a/apps/app/components/project/cycles/board-view/single-board.tsx b/apps/app/components/project/cycles/board-view/single-board.tsx new file mode 100644 index 000000000..c437824e4 --- /dev/null +++ b/apps/app/components/project/cycles/board-view/single-board.tsx @@ -0,0 +1,377 @@ +// react +import React, { useState } from "react"; +// next +import Link from "next/link"; +import Image from "next/image"; +// swr +import useSWR from "swr"; +// services +import cycleServices from "lib/services/cycles.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// ui +import { Spinner } from "ui"; +// icons +import { + ArrowsPointingInIcon, + ArrowsPointingOutIcon, + CalendarDaysIcon, + PlusIcon, + EllipsisHorizontalIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +import User from "public/user.png"; +// types +import { + CycleIssueResponse, + ICycle, + IIssue, + IWorkspaceMember, + NestedKeyOf, + Properties, +} from "types"; +// constants +import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { + addSpaceIfCamelCase, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; +import { Menu, Transition } from "@headlessui/react"; +import workspaceService from "lib/services/workspace.service"; + +type Props = { + properties: Properties; + groupedByIssues: { + [key: string]: IIssue[]; + }; + selectedGroup: NestedKeyOf | null; + groupTitle: string; + createdBy: string | null; + bgColor?: string; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; + openIssuesListModal: (cycleId: string) => void; + removeIssueFromCycle: (cycleId: string, bridgeId: string) => void; +}; + +const SingleCycleBoard: React.FC = ({ + properties, + groupedByIssues, + selectedGroup, + groupTitle, + createdBy, + bgColor, + openCreateIssueModal, + openIssuesListModal, + removeIssueFromCycle, +}) => { + // Collapse/Expand + const [show, setState] = useState(true); + + const { activeWorkspace, activeProject } = useUser(); + + if (selectedGroup === "priority") + groupTitle === "high" + ? (bgColor = "#dc2626") + : groupTitle === "medium" + ? (bgColor = "#f97316") + : groupTitle === "low" + ? (bgColor = "#22c55e") + : (bgColor = "#ff0000"); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( +
+
+
+
+
+ +

+ {groupTitle === null || groupTitle === "null" + ? "None" + : createdBy + ? createdBy + : addSpaceIfCamelCase(groupTitle)} +

+ + {groupedByIssues[groupTitle].length} + +
+ + + + + + + + +
+ + {(active) => ( + + )} + + + {(active) => ( + + )} + +
+
+
+
+
+
+
+ {groupedByIssues[groupTitle].map((childIssue, index: number) => { + const assignees = [ + ...(childIssue?.assignees_list ?? []), + ...(childIssue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find((p) => p.member.id === assignee)?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( +
+
+ + + {properties.key && ( +
+ {activeProject?.identifier}-{childIssue.sequence_id} +
+ )} +
+ {childIssue.name} +
+
+ +
+ {properties.priority && ( +
+ {/* {getPriorityIcon(childIssue.priority ?? "")} */} + {childIssue.priority ?? "None"} +
+
Priority
+
+ {childIssue.priority ?? "None"} +
+
+
+ )} + {properties.state && ( +
+ + {addSpaceIfCamelCase(childIssue.state_detail.name)} +
+
State
+
{childIssue.state_detail.name}
+
+
+ )} + {properties.start_date && ( +
+ + {childIssue.start_date + ? renderShortNumericDateFormat(childIssue.start_date) + : "N/A"} +
+
Started at
+
{renderShortNumericDateFormat(childIssue.start_date ?? "")}
+
+
+ )} + {properties.target_date && ( +
+ + {childIssue.target_date + ? renderShortNumericDateFormat(childIssue.target_date) + : "N/A"} +
+
Target date
+
{renderShortNumericDateFormat(childIssue.target_date ?? "")}
+
+ {childIssue.target_date && + (childIssue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + childIssue.target_date + )} days` + : findHowManyDaysLeft(childIssue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + childIssue.target_date + )} days` + : "Target date")} +
+
+
+ )} + {properties.assignee && ( +
+ {childIssue.assignee_details?.length > 0 ? ( + childIssue.assignee_details?.map((assignee, index: number) => ( +
+ {assignee.avatar && assignee.avatar !== "" ? ( +
+ {assignee.name} +
+ ) : ( +
+ {assignee.first_name.charAt(0)} +
+ )} +
+ )) + ) : ( +
+ No user +
+ )} +
+
Assigned to
+
+ {childIssue.assignee_details?.length > 0 + ? childIssue.assignee_details + .map((assignee) => assignee.first_name) + .join(", ") + : "No one"} +
+
+
+ )} +
+
+
+ ); + })} + +
+
+
+ ); +}; + +export default SingleCycleBoard; diff --git a/apps/app/components/project/cycles/list-view/index.tsx b/apps/app/components/project/cycles/list-view/index.tsx new file mode 100644 index 000000000..2ec0c6731 --- /dev/null +++ b/apps/app/components/project/cycles/list-view/index.tsx @@ -0,0 +1,352 @@ +// react +import React from "react"; +// next +import Link from "next/link"; +// swr +import useSWR from "swr"; +// headless ui +import { Disclosure, Transition, Menu } from "@headlessui/react"; +// services +import cycleServices from "lib/services/cycles.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// ui +import { Spinner } from "ui"; +// icons +import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, SelectSprintType } from "types"; +// fetch keys +import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// constants +import { + addSpaceIfCamelCase, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; +import workspaceService from "lib/services/workspace.service"; + +type Props = { + groupedByIssues: { + [key: string]: IIssue[]; + }; + properties: Properties; + selectedGroup: NestedKeyOf | null; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; + openIssuesListModal: (cycleId: string) => void; + removeIssueFromCycle: (cycleId: string, bridgeId: string) => void; +}; + +const CyclesListView: React.FC = ({ + groupedByIssues, + selectedGroup, + openCreateIssueModal, + openIssuesListModal, + properties, + removeIssueFromCycle, +}) => { + const { activeWorkspace, activeProject } = useUser(); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( +
+ {Object.keys(groupedByIssues).map((singleGroup) => ( + + {({ open }) => ( +
+
+ +
+ + + + {selectedGroup !== null ? ( +

+ {singleGroup === null || singleGroup === "null" + ? selectedGroup === "priority" && "No priority" + : addSpaceIfCamelCase(singleGroup)} +

+ ) : ( +

All Issues

+ )} +

+ {groupedByIssues[singleGroup as keyof IIssue].length} +

+
+
+
+ + +
+ {groupedByIssues[singleGroup] ? ( + groupedByIssues[singleGroup].length > 0 ? ( + groupedByIssues[singleGroup].map((issue: IIssue) => { + const assignees = [ + ...(issue?.assignees_list ?? []), + ...(issue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find( + (p) => p.member.id === assignee + )?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( +
+ +
+ {properties.priority && ( +
+ {/* {getPriorityIcon(issue.priority ?? "")} */} + {issue.priority ?? "None"} +
+
Priority
+
+ {issue.priority ?? "None"} +
+
+
+ )} + {properties.state && ( +
+ + {addSpaceIfCamelCase(issue?.state_detail.name)} +
+
State
+
{issue?.state_detail.name}
+
+
+ )} + {properties.start_date && ( +
+ + {issue.start_date + ? renderShortNumericDateFormat(issue.start_date) + : "N/A"} +
+
Started at
+
+ {renderShortNumericDateFormat(issue.start_date ?? "")} +
+
+
+ )} + {properties.target_date && ( +
+ + {issue.target_date + ? renderShortNumericDateFormat(issue.target_date) + : "N/A"} +
+
+ Target date +
+
+ {renderShortNumericDateFormat(issue.target_date ?? "")} +
+
+ {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + issue.target_date + )} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + issue.target_date + )} days` + : "Target date")} +
+
+
+ )} + + + + + + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); + }) + ) : ( +

No issues.

+ ) + ) : ( +
+ +
+ )} +
+
+
+
+ +
+
+ )} +
+ ))} +
+ // + // + // + // + // = ({ cycles }) => { + return ( + <> + {cycles.map((cycle) => ( + + ))} + + ); +}; + +export default CycleStatsView; diff --git a/apps/app/components/project/cycles/stats-view/single-stat.tsx b/apps/app/components/project/cycles/stats-view/single-stat.tsx new file mode 100644 index 000000000..ae5301fe9 --- /dev/null +++ b/apps/app/components/project/cycles/stats-view/single-stat.tsx @@ -0,0 +1,102 @@ +// next +import Link from "next/link"; +// swr +import useSWR from "swr"; +// services +import cyclesService from "lib/services/cycles.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// types +import { CycleIssueResponse, ICycle } from "types"; +// fetch-keys +import { CYCLE_ISSUES } from "constants/fetch-keys"; +import { groupBy, renderShortNumericDateFormat } from "constants/common"; +import { + CheckIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; + +type Props = { cycle: ICycle }; + +const stateGroupIcons: { + [key: string]: JSX.Element; +} = { + backlog: , + unstarted: , + started: , + cancelled: , + completed: , +}; + +const SingleStat: React.FC = ({ cycle }) => { + const { activeWorkspace, activeProject } = useUser(); + + const { data: cycleIssues } = useSWR( + activeWorkspace && activeProject && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, + activeWorkspace && activeProject && cycle.id + ? () => + cyclesService.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycle.id as string) + : null + ); + const groupedIssues = { + backlog: [], + unstarted: [], + started: [], + cancelled: [], + completed: [], + ...groupBy(cycleIssues ?? [], "issue_details.state_detail.group"), + }; + + // status calculator + const startDate = new Date(cycle.start_date ?? ""); + const endDate = new Date(cycle.end_date ?? ""); + const today = new Date(); + + return ( + <> +
+
+
+ + {cycle.name} + +
+ {renderShortNumericDateFormat(startDate)} + {" - "} + {renderShortNumericDateFormat(endDate)} +
+
+
+ {today.getDate() < startDate.getDate() + ? "Not started" + : today.getDate() > endDate.getDate() + ? "Over" + : "Active"} +
+
+
+
+ {Object.keys(groupedIssues).map((group) => { + return ( +
+
+
+ {stateGroupIcons[group]} +
+
+
+
{group}
+ {groupedIssues[group].length} +
+
+ ); + })} +
+
+
+ + ); +}; + +export default SingleStat; diff --git a/apps/app/components/project/issues/IssuesListModal.tsx b/apps/app/components/project/issues/IssuesListModal.tsx index aba9a0c34..b635a16b6 100644 --- a/apps/app/components/project/issues/IssuesListModal.tsx +++ b/apps/app/components/project/issues/IssuesListModal.tsx @@ -19,6 +19,7 @@ type Props = { issues: IIssue[]; title?: string; multiple?: boolean; + customDisplay?: JSX.Element; }; const IssuesListModal: React.FC = ({ @@ -29,6 +30,7 @@ const IssuesListModal: React.FC = ({ issues, title = "Issues", multiple = false, + customDisplay, }) => { const [query, setQuery] = useState(""); const [values, setValues] = useState([]); @@ -90,9 +92,10 @@ const IssuesListModal: React.FC = ({ className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none" placeholder="Search..." onChange={(e) => setQuery(e.target.value)} + displayValue={() => ""} />
- +
{customDisplay}
; @@ -71,25 +71,12 @@ const IssueDetailSidebar: React.FC = ({ }) => { const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); - const [isParentModalOpen, setIsParentModalOpen] = useState(false); const [createLabelForm, setCreateLabelForm] = useState(false); const { activeWorkspace, activeProject, cycles, issues } = useUser(); const { setToastAlert } = useToast(); - const { data: states } = useSWR( - activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null, - activeWorkspace && activeProject - ? () => stateServices.getStates(activeWorkspace.slug, activeProject.id) - : null - ); - - const { data: people } = useSWR( - activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null, - activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null - ); - const { data: issueLabels, mutate: issueLabelMutate } = useSWR( activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null, activeProject && activeWorkspace @@ -108,7 +95,7 @@ const IssueDetailSidebar: React.FC = ({ defaultValues, }); - const onSubmit = (formData: any) => { + const handleNewLabel = (formData: any) => { if (!activeWorkspace || !activeProject || isSubmitting) return; issuesServices .createIssueLabel(activeWorkspace.slug, activeProject.id, formData) @@ -133,58 +120,6 @@ const IssueDetailSidebar: React.FC = ({ }> > = [ [ - { - label: "Status", - name: "state", - canSelectMultipleOptions: false, - icon: Squares2X2Icon, - options: states?.map((state) => ({ - label: state.name, - value: state.id, - color: state.color, - })), - modal: false, - }, - { - label: "Assignees", - name: "assignees_list", - canSelectMultipleOptions: true, - icon: UserGroupIcon, - options: people?.map((person) => ({ - label: person.member.first_name, - value: person.member.id, - })), - modal: false, - }, - { - label: "Priority", - name: "priority", - canSelectMultipleOptions: false, - icon: ChartBarIcon, - options: PRIORITIES.map((property) => ({ - label: property, - value: property, - })), - modal: false, - }, - ], - [ - { - label: "Parent", - name: "parent", - canSelectMultipleOptions: false, - icon: UserIcon, - issuesList: - issues?.results.filter( - (i) => - i.id !== issueDetail?.id && - i.id !== issueDetail?.parent && - i.parent !== issueDetail?.id - ) ?? [], - modal: true, - isOpen: isParentModalOpen, - setIsOpen: setIsParentModalOpen, - }, // { // label: "Blocker", // name: "blockers_list", @@ -205,26 +140,6 @@ const IssueDetailSidebar: React.FC = ({ // isOpen: isBlockedModalOpen, // setIsOpen: setIsBlockedModalOpen, // }, - { - label: "Target Date", - name: "target_date", - canSelectMultipleOptions: true, - icon: CalendarDaysIcon, - modal: false, - }, - ], - [ - { - label: "Cycle", - name: "cycle", - canSelectMultipleOptions: false, - icon: ArrowPathIcon, - options: cycles?.map((cycle) => ({ - label: cycle.name, - value: cycle.id, - })), - modal: false, - }, ], ]; @@ -297,7 +212,69 @@ const IssueDetailSidebar: React.FC = ({
- {sidebarSections.map((section, index) => ( +
+ + + +
+
+ + i.id !== issueDetail?.id && + i.id !== issueDetail?.parent && + i.parent !== issueDetail?.id + ) ?? [] + } + customDisplay={ + issueDetail?.parent_detail ? ( + + ) : ( +
+ No parent selected +
+ ) + } + watchIssue={watchIssue} + /> +
+
+ +

Due date

+
+
+ ( + { + submitChanges({ target_date: e.target.value }); + onChange(e.target.value); + }} + className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full" + /> + )} + /> +
+
+
+
+ +
+ {/* {sidebarSections.map((section, index) => (
{section.map((item) => (
@@ -306,154 +283,63 @@ const IssueDetailSidebar: React.FC = ({

{item.label}

- {item.name === "target_date" ? ( - ( - { - submitChanges({ target_date: e.target.value }); - onChange(e.target.value); + ( + <> + item.setIsOpen && item.setIsOpen(false)} + onChange={(val) => { + console.log(val); + submitChanges({ [item.name]: val }); + onChange(val); }} - className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full" + issues={item?.issuesList ?? []} + title={`Select ${item.label}`} + multiple={item.canSelectMultipleOptions} + value={value} + customDisplay={ + issueDetail?.parent_detail ? ( + + ) : ( +
+ No parent selected +
+ ) + } /> - )} - /> - ) : item.modal ? ( - ( - <> - item.setIsOpen && item.setIsOpen(false)} - onChange={(val) => { - console.log(val); - // submitChanges({ [item.name]: val }); - onChange(val); - }} - issues={item?.issuesList ?? []} - title={`Select ${item.label}`} - multiple={item.canSelectMultipleOptions} - value={value} - /> - - - )} - /> - ) : ( - ( - { - if (item.name === "cycle") handleCycleChange(value); - else submitChanges({ [item.name]: value }); - }} - className="flex-shrink-0" - > - {({ open }) => ( -
- - - {value - ? Array.isArray(value) - ? value - .map( - (i: any) => - item.options?.find((option) => option.value === i) - ?.label - ) - .join(", ") || item.label - : item.options?.find((option) => option.value === value) - ?.label - : "None"} - - - - - - -
- {item.options ? ( - item.options.length > 0 ? ( - item.options.map((option) => ( - - `${ - active || selected - ? "text-white bg-theme" - : "text-gray-900" - } ${ - item.label === "Priority" && "capitalize" - } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` - } - value={option.value} - > - {option.color && ( - - )} - {option.label} - - )) - ) : ( -
No {item.label}s found
- ) - ) : ( - - )} -
-
-
-
- )} -
- )} - /> - )} + : `Select ${item.label}`} + + + )} + />
))}
- ))} + ))} */}
@@ -466,18 +352,20 @@ const IssueDetailSidebar: React.FC = ({ {issueDetail?.label_details.map((label) => ( - // submitChanges({ - // labels_list: issueDetail?.labels_list.filter((l) => l !== label.id), - // }) - // } + className="group flex items-center gap-1 border rounded-2xl text-xs px-1 py-0.5 hover:bg-red-50 hover:border-red-500 cursor-pointer" + onClick={() => { + const updatedLabels = issueDetail?.labels.filter((l) => l !== label.id); + submitChanges({ + labels_list: updatedLabels, + }); + }} > {label.name} + ))} = ({ as="div" value={value} multiple - onChange={(value: any) => submitChanges({ labels_list: value })} + onChange={(val) => submitChanges({ labels_list: val })} className="flex-shrink-0" > {({ open }) => ( <> Label
- - - Select Label - + + Select Label = ({ )} /> +
-
- -
{createLabelForm && ( -
+
{({ open }) => ( diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx new file mode 100644 index 000000000..0fff8006b --- /dev/null +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx @@ -0,0 +1,181 @@ +// react +import React from "react"; +// next +import Image from "next/image"; +// swr +import useSWR from "swr"; +// react-hook-form +import { Control, Controller } from "react-hook-form"; +// services +import workspaceService from "lib/services/workspace.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// ui +import { Spinner } from "ui"; +// icons +import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; +import User from "public/user.png"; +// types +import { IIssue } from "types"; +// constants +import { classNames } from "constants/common"; +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; + +type Props = { + control: Control; + submitChanges: (formData: Partial) => void; +}; + +const SelectAssignee: React.FC = ({ control, submitChanges }) => { + const { activeWorkspace } = useUser(); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( +
+
+ +

Assignees

+
+
+ ( + { + submitChanges({ assignees_list: value }); + }} + className="flex-shrink-0" + > + {({ open }) => ( +
+ + +
+ {value && Array.isArray(value) ? ( + <> + {value.length > 0 ? ( + value.map((assignee, index: number) => { + const person = people?.find( + (p) => p.member.id === assignee + )?.member; + + return ( +
+ {person && person.avatar && person.avatar !== "" ? ( +
+ {person.first_name} +
+ ) : ( +
+ {person?.first_name.charAt(0)} +
+ )} +
+ ); + }) + ) : ( +
+ No user +
+ )} + + ) : null} +
+
+
+ + + +
+ {people ? ( + people.length > 0 ? ( + people.map((option) => ( + + `${ + active || selected ? "text-white bg-theme" : "text-gray-900" + } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` + } + value={option.member.id} + > + {option.member.avatar && option.member.avatar !== "" ? ( +
+ avatar +
+ ) : ( +
+ {option.member.first_name && option.member.first_name !== "" + ? option.member.first_name.charAt(0) + : option.member.email.charAt(0)} +
+ )} + {option.member.first_name} +
+ )) + ) : ( +
No assignees found
+ ) + ) : ( + + )} +
+
+
+
+ )} +
+ )} + /> +
+
+ ); +}; + +export default SelectAssignee; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx new file mode 100644 index 000000000..0e36c54af --- /dev/null +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx @@ -0,0 +1,98 @@ +// react-hook-form +import { Control, Controller } from "react-hook-form"; +// hooks +import useUser from "lib/hooks/useUser"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// types +import { IIssue } from "types"; +import { classNames } from "constants/common"; +import { Spinner } from "ui"; +import React from "react"; +import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; + +type Props = { + control: Control; + handleCycleChange: (cycleId: string) => void; +}; + +const SelectCycle: React.FC = ({ control, handleCycleChange }) => { + const { cycles } = useUser(); + + return ( +
+
+ +

Cycle

+
+
+ ( + { + handleCycleChange(value); + }} + className="flex-shrink-0" + > + {({ open }) => ( +
+ + + {value ? cycles?.find((c) => c.id === value)?.name : "None"} + + + + + + +
+ {cycles ? ( + cycles.length > 0 ? ( + cycles.map((option) => ( + + `${ + active || selected ? "text-white bg-theme" : "text-gray-900" + } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` + } + value={option.id} + > + {option.name} + + )) + ) : ( +
No cycles found
+ ) + ) : ( + + )} +
+
+
+
+ )} +
+ )} + /> +
+
+ ); +}; + +export default SelectCycle; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx new file mode 100644 index 000000000..03cf0be4e --- /dev/null +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx @@ -0,0 +1,74 @@ +// react +import React, { useState } from "react"; +// react-hook-form +import { Control, Controller, UseFormWatch } from "react-hook-form"; +// hooks +import useUser from "lib/hooks/useUser"; +// components +import IssuesListModal from "components/project/issues/IssuesListModal"; +// icons +import { UserIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue } from "types"; + +type Props = { + control: Control; + submitChanges: (formData: Partial) => void; + issuesList: IIssue[]; + customDisplay: JSX.Element; + watchIssue: UseFormWatch; +}; + +const SelectParent: React.FC = ({ + control, + submitChanges, + issuesList, + customDisplay, + watchIssue, +}) => { + const [isParentModalOpen, setIsParentModalOpen] = useState(false); + + const { activeProject, issues } = useUser(); + + return ( +
+
+ +

Parent

+
+
+ ( + setIsParentModalOpen(false)} + onChange={(val) => { + submitChanges({ parent: val }); + onChange(val); + }} + issues={issuesList} + title="Select Parent" + value={value} + customDisplay={customDisplay} + /> + )} + /> + +
+
+ ); +}; + +export default SelectParent; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx new file mode 100644 index 000000000..e76beeb25 --- /dev/null +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx @@ -0,0 +1,84 @@ +// react +import React from "react"; +// react-hook-form +import { Control, Controller } from "react-hook-form"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// icons +import { ChevronDownIcon, ChartBarIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue } from "types"; +// constants +import { classNames } from "constants/common"; +import { PRIORITIES } from "constants/"; + +type Props = { + control: Control; + submitChanges: (formData: Partial) => void; +}; + +const SelectPriority: React.FC = ({ control, submitChanges }) => { + return ( +
+
+ +

Priority

+
+
+ ( + { + submitChanges({ priority: value }); + }} + className="flex-shrink-0" + > + {({ open }) => ( +
+ + + {value} + + + + + + +
+ {PRIORITIES.map((option) => ( + + `${ + active || selected ? "text-white bg-theme" : "text-gray-900" + } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate capitalize` + } + value={option} + > + {option} + + ))} +
+
+
+
+ )} +
+ )} + /> +
+
+ ); +}; + +export default SelectPriority; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx new file mode 100644 index 000000000..cc129c536 --- /dev/null +++ b/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx @@ -0,0 +1,116 @@ +// react-hook-form +import { Control, Controller } from "react-hook-form"; +// hooks +import useUser from "lib/hooks/useUser"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// types +import { IIssue } from "types"; +import { classNames } from "constants/common"; +import { Spinner } from "ui"; +import React from "react"; +import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; + +type Props = { + control: Control; + submitChanges: (formData: Partial) => void; +}; + +const SelectState: React.FC = ({ control, submitChanges }) => { + const { states } = useUser(); + + return ( +
+
+ +

State

+
+
+ ( + { + submitChanges({ state: value }); + }} + className="flex-shrink-0" + > + {({ open }) => ( +
+ + + {value ? ( + <> + option.id === value)?.color, + }} + > + {states?.find((option) => option.id === value)?.name} + + ) : ( + "None" + )} + + + + + + +
+ {states ? ( + states.length > 0 ? ( + states.map((option) => ( + + `${ + active || selected ? "text-white bg-theme" : "text-gray-900" + } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` + } + value={option.id} + > + {option.color && ( + + )} + {option.name} + + )) + ) : ( +
No states found
+ ) + ) : ( + + )} +
+
+
+
+ )} +
+ )} + /> +
+
+ ); +}; + +export default SelectState; diff --git a/apps/app/components/sidebar/workspace-options.tsx b/apps/app/components/sidebar/workspace-options.tsx index b7760a49e..749eba17d 100644 --- a/apps/app/components/sidebar/workspace-options.tsx +++ b/apps/app/components/sidebar/workspace-options.tsx @@ -82,9 +82,7 @@ const WorkspaceOptions: React.FC = ({ sidebarCollapse }) => { return (
-
+
= ({ sidebarCollapse }) => { alt="Workspace Logo" layout="fill" objectFit="cover" + className="rounded" /> ) : ( activeWorkspace?.name?.charAt(0) ?? "N" )}
{!sidebarCollapse && ( -

- {activeWorkspace?.name ?? "Loading..."} +

+ {activeWorkspace?.name + ? activeWorkspace.name.length > 17 + ? `${activeWorkspace.name.substring(0, 17)}...` + : activeWorkspace.name + : "Loading..."}

)}
@@ -131,74 +134,124 @@ const WorkspaceOptions: React.FC = ({ sidebarCollapse }) => { leaveTo="transform opacity-0 scale-95" > -
- {workspaces ? ( - <> - {workspaces.length > 0 ? ( - workspaces.map((workspace: any) => ( - - {({ active }) => ( - - )} - - )) - ) : ( -

No workspace found!

- )} - { - router.push("/create-workspace"); - }} - className="w-full" - > - {({ active }) => ( - - - Create Workspace - +
+
+ + {user?.email} + +
+
+ {workspaces ? ( + <> + {workspaces.length > 0 ? ( + workspaces.map((workspace: any) => ( + + {({ active }) => ( + + )} + + )) + ) : ( +

No workspace found!

)} + { + router.push("/create-workspace"); + }} + className="w-full text-xs flex items-center gap-2 px-2 py-1 text-left rounded hover:bg-gray-100" + > + + Create Workspace + + + ) : ( +
+ +
+ )} +
+
+ {userLinks.map((link, index) => ( + + + + {link.name} + + - - ) : ( -
- -
- )} + ))} + { + await authenticationService + .signOut({ + refresh_token: authenticationService.getRefreshToken(), + }) + .then((response) => { + console.log("user signed out", response); + }) + .catch((error) => { + console.log("Failed to sign out", error); + }) + .finally(() => { + mutateUser(); + router.push("/signin"); + }); + }} + > + Sign out + +
- {!sidebarCollapse && ( + {/* {!sidebarCollapse && (
@@ -261,7 +314,7 @@ const WorkspaceOptions: React.FC = ({ sidebarCollapse }) => {
- )} + )} */}
{workspaceLinks.map((link, index) => ( diff --git a/apps/app/layouts/app-layout.tsx b/apps/app/layouts/app-layout.tsx index fd0f0b134..9f1500a6d 100644 --- a/apps/app/layouts/app-layout.tsx +++ b/apps/app/layouts/app-layout.tsx @@ -18,7 +18,9 @@ const AppLayout: React.FC = ({ children, noPadding = false, bg = "primary", + noHeader = false, breadcrumbs, + left, right, }) => { const [isOpen, setIsOpen] = useState(false); @@ -37,7 +39,7 @@ const AppLayout: React.FC = ({
-
+ {noHeader ? null :
}
{ - const [selectedWorkspace, setSelectedWorkspace] = useState(null); + const { activeWorkspace, user, states } = useUser(); - const { user, workspaces, activeWorkspace } = useUser(); + console.log(states); const { data: myIssues, mutate: mutateMyIssues } = useSWR( user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null, user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null ); + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + const [properties, setProperties] = useIssuesProperties( + activeWorkspace?.slug, + "21b5fab2-cb0c-4875-9496-619134bf1f32" + ); + const updateMyIssues = ( workspaceSlug: string, projectId: string, @@ -79,98 +105,515 @@ const MyIssues: NextPage = () => { } right={ - { - const e = new KeyboardEvent("keydown", { - key: "i", - ctrlKey: true, - }); - document.dispatchEvent(e); - }} - /> +
+ + {({ open }) => ( + <> + + View + + + + +
+
+

Properties

+
+ {Object.keys(properties).map((key) => ( + + ))} +
+
+
+
+
+ + )} +
+ { + const e = new KeyboardEvent("keydown", { + key: "i", + ctrlKey: true, + }); + document.dispatchEvent(e); + }} + /> +
} >
{myIssues ? ( <> {myIssues.length > 0 ? ( -
-
-
-
- - - - - - - - - - - - {myIssues.map((myIssue, index) => ( - - - - - - - - ))} - -
- NAME - - DESCRIPTION - - PROJECT - - PRIORITY - - STATUS -
- - {myIssue.name} - - - {/* {myIssue.description} */} - - {myIssue.project_detail?.name} -
- {`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`} -
{myIssue.priority} - -
+
+ + {({ open }) => ( +
+
+ +
+ + + +

My Issues

+

{myIssues.length}

+
+
+
+ + +
+ {myIssues.map((issue: IIssue) => { + const assignees = [ + ...(issue?.assignees_list ?? []), + ...(issue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find( + (p) => p.member.id === assignee + )?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( +
+ +
+ {properties.priority && ( + { + // partialUpdateIssue({ priority: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
+ + {issue.priority ?? "None"} + + + + + {PRIORITIES?.map((priority) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer capitalize select-none px-3 py-2" + ) + } + value={priority} + > + {priority} + + ))} + + +
+
+
+ Priority +
+
+ {issue.priority ?? "None"} +
+
+ + )} +
+ )} + {properties.state && ( + { + // partialUpdateIssue({ state: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
+ + + {addSpaceIfCamelCase(issue.state_detail.name)} + + + + + {states?.map((state) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none px-3 py-2" + ) + } + value={state.id} + > + {addSpaceIfCamelCase(state.name)} + + ))} + + +
+
+
State
+
{issue.state_detail.name}
+
+ + )} +
+ )} + {properties.start_date && ( +
+ + {issue.start_date + ? renderShortNumericDateFormat(issue.start_date) + : "N/A"} +
+
Started at
+
+ {renderShortNumericDateFormat(issue.start_date ?? "")} +
+
+
+ )} + {properties.target_date && ( +
+ + {issue.target_date + ? renderShortNumericDateFormat(issue.target_date) + : "N/A"} +
+
+ Target date +
+
+ {renderShortNumericDateFormat(issue.target_date ?? "")} +
+
+ {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + issue.target_date + )} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + issue.target_date + )} days` + : "Target date")} +
+
+
+ )} + {properties.assignee && ( + { + const newData = issue.assignees ?? []; + if (newData.includes(data)) { + newData.splice(newData.indexOf(data), 1); + } else { + newData.push(data); + } + // partialUpdateIssue({ assignees_list: newData }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
+ +
+ {assignees.length > 0 ? ( + assignees.map((assignee, index: number) => ( +
+ {assignee.avatar && + assignee.avatar !== "" ? ( +
+ {assignee?.first_name} +
+ ) : ( +
+ {assignee.first_name?.charAt(0)} +
+ )} +
+ )) + ) : ( +
+ No user +
+ )} +
+
+ + + + {people?.map((person) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none p-2" + ) + } + value={person.member.id} + > +
+ {person.member.avatar && + person.member.avatar !== "" ? ( +
+ avatar +
+ ) : ( +
+ {person.member.first_name && + person.member.first_name !== "" + ? person.member.first_name.charAt(0) + : person.member.email.charAt(0)} +
+ )} +

+ {person.member.first_name && + person.member.first_name !== "" + ? person.member.first_name + : person.member.email} +

+
+
+ ))} +
+
+
+
+
Assigned to
+
+ {issue.assignee_details?.length > 0 + ? issue.assignee_details + .map((assignee) => assignee.first_name) + .join(", ") + : "No one"} +
+
+ + )} +
+ )} + + + + + + + + + +
+ +
+
+
+
+
+
+ ); + })} +
+
+
-
-
+ )} +
) : (
diff --git a/apps/app/pages/projects/[projectId]/cycles/[cycleId].tsx b/apps/app/pages/projects/[projectId]/cycles/[cycleId].tsx index f1dc51c6b..1f339bec1 100644 --- a/apps/app/pages/projects/[projectId]/cycles/[cycleId].tsx +++ b/apps/app/pages/projects/[projectId]/cycles/[cycleId].tsx @@ -1,6 +1,7 @@ // react -import React from "react"; +import React, { useState } from "react"; // next +import Link from "next/link"; import { useRouter } from "next/router"; // swr import useSWR, { mutate } from "swr"; @@ -9,8 +10,8 @@ import { DropResult } from "react-beautiful-dnd"; // layouots import AppLayout from "layouts/app-layout"; // components -import CyclesListView from "components/project/cycles/ListView"; -import CyclesBoardView from "components/project/cycles/BoardView"; +import CyclesListView from "components/project/cycles/list-view"; +import CyclesBoardView from "components/project/cycles/board-view"; // services import issuesServices from "lib/services/issues.service"; import cycleServices from "lib/services/cycles.service"; @@ -25,19 +26,21 @@ import { Menu, Popover, Transition } from "@headlessui/react"; import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui"; // icons import { Squares2X2Icon } from "@heroicons/react/20/solid"; -import { - ArrowPathIcon, - ChevronDownIcon, - EllipsisHorizontalIcon, - ListBulletIcon, -} from "@heroicons/react/24/outline"; +import { ArrowPathIcon, ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline"; // types -import { CycleIssueResponse, IIssue, NestedKeyOf, Properties } from "types"; +import { + CycleIssueResponse, + IIssue, + NestedKeyOf, + Properties, + SelectIssue, + SelectSprintType, +} from "types"; // fetch-keys import { CYCLE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys"; // constants import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; -import Link from "next/link"; +import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; const groupByOptions: Array<{ name: string; key: NestedKeyOf | null }> = [ { name: "State", key: "state_detail.name" }, @@ -73,6 +76,10 @@ const filterIssueOptions: Array<{ type Props = {}; const SingleCycle: React.FC = () => { + const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); + const [selectedCycle, setSelectedCycle] = useState(); + const [selectedIssues, setSelectedIssues] = useState(); + const { activeWorkspace, activeProject, cycles } = useUser(); const router = useRouter(); @@ -121,6 +128,21 @@ const SingleCycle: React.FC = () => { filterIssue, } = useIssuesFilter(cycleIssuesArray ?? []); + const openCreateIssueModal = ( + issue?: IIssue, + actionType: "create" | "edit" | "delete" = "create" + ) => { + const cycle = cycles?.find((cycle) => cycle.id === cycleId); + if (cycle) { + setSelectedCycle({ + ...cycle, + actionType: "create-issue", + }); + if (issue) setSelectedIssues({ ...issue, actionType }); + setIsIssueModalOpen(true); + } + }; + const addIssueToCycle = (cycleId: string, issueId: string) => { if (!activeWorkspace || !activeProject?.id) return; @@ -200,18 +222,32 @@ const SingleCycle: React.FC = () => { }; return ( - - - {/* c.id === cycleId)?.name ?? "Cycle"} `} /> */} + <> + + + + + } + left={ - + - Cycle + {cycles?.find((c) => c.id === cycleId)?.name} = () => { - - } - right={ -
-
- - -
- - {({ open }) => ( - <> - - View - + } + right={ +
+
+ + +
+ + {({ open }) => ( + <> + + View + - - -
-
-

Group by

- option.key === groupByProperty)?.name ?? - "Select" - } - > - {groupByOptions.map((option) => ( - setGroupByProperty(option.key)} - > - {option.name} - - ))} - -
-
-

Order by

- option.key === orderBy)?.name ?? - "Select" - } - > - {orderByOptions.map((option) => - groupByProperty === "priority" && option.key === "priority" ? null : ( + + +
+
+

Group by

+ option.key === groupByProperty) + ?.name ?? "Select" + } + > + {groupByOptions.map((option) => ( setOrderBy(option.key)} + onClick={() => setGroupByProperty(option.key)} > {option.name} - ) - )} - -
-
-

Issue type

- option.key === filterIssue)?.name ?? - "Select" - } - > - {filterIssueOptions.map((option) => ( - setFilterIssue(option.key)} - > - {option.name} - - ))} - -
-
-
-

Properties

-
- {Object.keys(properties).map((key) => ( - - ))} + ))} + +
+
+

Order by

+ option.key === orderBy)?.name ?? + "Select" + } + > + {orderByOptions.map((option) => + groupByProperty === "priority" && option.key === "priority" ? null : ( + setOrderBy(option.key)} + > + {option.name} + + ) + )} + +
+
+

Issue type

+ option.key === filterIssue) + ?.name ?? "Select" + } + > + {filterIssueOptions.map((option) => ( + setFilterIssue(option.key)} + > + {option.name} + + ))} + +
+
+
+

Properties

+
+ {Object.keys(properties).map((key) => ( + + ))} +
-
-
-
- - )} - -
- } - > - {issueView === "list" ? ( - { - return; - }} - openIssuesListModal={() => { - return; - }} - removeIssueFromCycle={removeIssueFromCycle} - /> - ) : ( -
- + + + )} + +
+ } + > + {issueView === "list" ? ( + { - return; - }} + properties={properties} + openCreateIssueModal={openCreateIssueModal} openIssuesListModal={() => { return; }} + removeIssueFromCycle={removeIssueFromCycle} /> -
- )} - + ) : ( +
+ { + return; + }} + /> +
+ )} + + ); }; diff --git a/apps/app/pages/projects/[projectId]/cycles/index.tsx b/apps/app/pages/projects/[projectId]/cycles/index.tsx index 9a2709500..16d3d2227 100644 --- a/apps/app/pages/projects/[projectId]/cycles/index.tsx +++ b/apps/app/pages/projects/[projectId]/cycles/index.tsx @@ -3,11 +3,9 @@ import React, { useEffect, useState } from "react"; // next import { useRouter } from "next/router"; import type { NextPage } from "next"; -import Link from "next/link"; // swr import useSWR from "swr"; // services -import issuesServices from "lib/services/issues.service"; import sprintService from "lib/services/cycles.service"; // hooks import useUser from "lib/hooks/useUser"; @@ -24,6 +22,7 @@ import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deleti import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal"; +import CycleStatsView from "components/project/cycles/stats-view"; // headless ui import { Popover, Transition } from "@headlessui/react"; // ui @@ -204,11 +203,7 @@ const ProjectSprints: NextPage = () => { {cycles ? ( cycles.length > 0 ? (
- {cycles.map((cycle) => ( - - {cycle.name} - - ))} +
) : (
diff --git a/apps/app/pages/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/projects/[projectId]/issues/[issueId].tsx index e4eb8d2a2..3940e3a1e 100644 --- a/apps/app/pages/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/projects/[projectId]/issues/[issueId].tsx @@ -1,4 +1,5 @@ // next +import Link from "next/link"; import type { NextPage } from "next"; import { useRouter } from "next/router"; import dynamic from "next/dynamic"; @@ -27,10 +28,12 @@ import AppLayout from "layouts/app-layout"; // components import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection"; +import AddAsSubIssue from "components/command-palette/addAsSubIssue"; +import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; // common import { debounce } from "constants/common"; // components -import IssueDetailSidebar from "components/project/issues/issue-detail/IssueDetailSidebar"; +import IssueDetailSidebar from "components/project/issues/issue-detail/issue-detail-sidebar"; // activites import IssueActivitySection from "components/project/issues/issue-detail/activity"; // ui @@ -46,9 +49,6 @@ import { EllipsisHorizontalIcon, PlusIcon, } from "@heroicons/react/24/outline"; -import Link from "next/link"; -import AddAsSubIssue from "components/command-palette/addAsSubIssue"; -import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; const RichTextEditor = dynamic(() => import("components/lexical/editor"), { ssr: false, diff --git a/apps/app/pages/projects/[projectId]/settings.tsx b/apps/app/pages/projects/[projectId]/settings.tsx index 6e7800d0e..18d5ed567 100644 --- a/apps/app/pages/projects/[projectId]/settings.tsx +++ b/apps/app/pages/projects/[projectId]/settings.tsx @@ -13,6 +13,7 @@ import { Tab } from "@headlessui/react"; import withAuth from "lib/hoc/withAuthWrapper"; // layouts import SettingsLayout from "layouts/settings-layout"; +import AppLayout from "layouts/app-layout"; // service import projectServices from "lib/services/project.service"; // hooks @@ -155,14 +156,14 @@ const ProjectSettings: NextPage = () => { ]; return ( - } - links={sidebarLinks} + // links={sidebarLinks} > {projectDetails ? (
@@ -209,7 +210,7 @@ const ProjectSettings: NextPage = () => {
)} -
+ ); }; diff --git a/apps/app/pages/workspace/index.tsx b/apps/app/pages/workspace/index.tsx index 94921eaf2..3c671e5f7 100644 --- a/apps/app/pages/workspace/index.tsx +++ b/apps/app/pages/workspace/index.tsx @@ -18,9 +18,14 @@ import userService from "lib/services/user.service"; // ui import { Spinner } from "ui"; // icons -import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { ArrowRightIcon, CalendarDaysIcon } from "@heroicons/react/24/outline"; // types import type { IIssue } from "types"; +import { + addSpaceIfCamelCase, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; const Workspace: NextPage = () => { const { user, activeWorkspace, projects } = useUser(); @@ -46,7 +51,7 @@ const Workspace: NextPage = () => { const hours = new Date().getHours(); return ( - +
{user ? (
@@ -78,49 +83,105 @@ const Workspace: NextPage = () => {
{myIssues ? ( myIssues.length > 0 ? ( - - - - - - - - - - {myIssues?.map((issue, index) => ( - - - - - - ))} - -
- ISSUE - - KEY - - STATUS -
- - {issue.name} - - - {issue.project_detail?.identifier}-{issue.sequence_id} - - - {issue.state_detail.name ?? "None"} - -
+
+
+
+
+

My Issues

+

{myIssues.length}

+
+
+
+ {myIssues.map((issue) => ( +
+ +
+
+ {issue.priority ?? "None"} +
+ +
+ + {addSpaceIfCamelCase(issue.state_detail.name)} +
+
+ + {issue.target_date + ? renderShortNumericDateFormat(issue.target_date) + : "N/A"} +
+
Target date
+
{renderShortNumericDateFormat(issue.target_date ?? "")}
+
+ {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + issue.target_date + )} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + issue.target_date + )} days` + : "Target date")} +
+
+
+
+
+ ))} +
+
+
) : (

No Issues Found

From 41271a5e3fa3ef4053570944802be5b8bc74c1d9 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Thu, 15 Dec 2022 10:35:05 +0530 Subject: [PATCH 048/104] feat: can't delete state with issues --- .../BoardView/state/confirm-state-delete.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx index d39962ca1..ae1d2d4d1 100644 --- a/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx +++ b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx @@ -9,6 +9,8 @@ import stateServices from "lib/services/state.service"; import { STATE_LIST } from "constants/fetch-keys"; // hooks import useUser from "lib/hooks/useUser"; +// common +import { groupBy } from "constants/common"; // icons import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // ui @@ -25,7 +27,9 @@ type Props = { const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const { activeWorkspace } = useUser(); + const [issuesWithThisStateExist, setIssuesWithThisStateExist] = useState(true); + + const { activeWorkspace, issues } = useUser(); const cancelButtonRef = useRef(null); @@ -36,7 +40,7 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { const handleDeletion = async () => { setIsDeleteLoading(true); - if (!data || !activeWorkspace) return; + if (!data || !activeWorkspace || issuesWithThisStateExist) return; await stateServices .deleteState(activeWorkspace.slug, data.project, data.id) .then(() => { @@ -53,6 +57,12 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { }); }; + const groupedIssues = groupBy(issues?.results ?? [], "state"); + + useEffect(() => { + if (data) setIssuesWithThisStateExist(!!groupedIssues[data.id]); + }, [groupedIssues]); + return ( = ({ isOpen, onClose, data }) => { This action cannot be undone.

+
+ {issuesWithThisStateExist && ( +

+ There are issues with this state. Please move them to another state + before deleting this state. +

+ )} +
@@ -113,7 +131,7 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { type="button" onClick={handleDeletion} theme="danger" - disabled={isDeleteLoading} + disabled={isDeleteLoading || issuesWithThisStateExist} className="inline-flex sm:ml-3" > {isDeleteLoading ? "Deleting..." : "Delete"} From fa611b06312460346f47f4c45940a933641a1488 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 02:57:50 +0530 Subject: [PATCH 049/104] feat: project modules --- apiserver/plane/api/serializers/__init__.py | 2 + apiserver/plane/api/serializers/module.py | 136 ++++++++++++++++++++ apiserver/plane/api/urls.py | 48 +++++++ apiserver/plane/api/views/__init__.py | 2 + apiserver/plane/api/views/module.py | 109 ++++++++++++++++ apiserver/plane/db/models/__init__.py | 2 + apiserver/plane/db/models/module.py | 88 +++++++++++++ 7 files changed, 387 insertions(+) create mode 100644 apiserver/plane/api/serializers/module.py create mode 100644 apiserver/plane/api/views/module.py create mode 100644 apiserver/plane/db/models/module.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index abc42cb4b..e2d474901 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -38,3 +38,5 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, ) + +from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py new file mode 100644 index 000000000..e847c581a --- /dev/null +++ b/apiserver/plane/api/serializers/module.py @@ -0,0 +1,136 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .project import ProjectSerializer +from .issue import IssueFlatSerializer + +from plane.db.models import User, Module, ModuleMember, ModuleIssue + + +class ModuleWriteSerializer(BaseSerializer): + + members_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data): + + members = validated_data.pop("members_list", None) + + project = self.context["project"] + + module = Module.objects.create(**validated_data, project=project) + + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ) + + return module + + def update(self, instance, validated_data): + + members = validated_data.pop("members_list", None) + + project = self.context["project"] + + module = Module.objects.create(**validated_data, project=project) + + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class ModuleFlatSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleIssueSerializer(BaseSerializer): + + module_detail = ModuleFlatSerializer(read_only=True, source="module") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleSerializer(BaseSerializer): + + project_detail = ProjectSerializer(read_only=True, source="project") + lead_detail = UserLiteSerializer(read_only=True, source="lead") + members_detail = UserLiteSerializer(read_only=True, many=True, source="members") + module_issues = ModuleIssueSerializer(read_only=True, many=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 149dc3329..ef53b4519 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -54,6 +54,8 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkAssignIssuesToCycleEndpoint, ProjectUserViewsEndpoint, + ModuleViewSet, + ModuleIssueViewSet, UserLastProjectWithWorkspaceEndpoint, UserWorkSpaceIssues, ) @@ -587,6 +589,52 @@ urlpatterns = [ name="File Assets", ), ## End File Assets + ## Modules + path( + "workspaces//projects//modules/", + ModuleViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//", + ModuleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//module-issues/", + ModuleIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-module-issues", + ), + path( + "workspaces//projects//modules//module-issues//", + ModuleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-module-issues", + ), + ## End Modules # path( # "issues//all/", # IssueViewSet.as_view({"get": "list_issue_history_comments"}), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index a4d9021c6..b55342f87 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -66,3 +66,5 @@ from .authentication import ( MagicSignInEndpoint, MagicSignInGenerateEndpoint, ) + +from .module import ModuleViewSet, ModuleIssueViewSet diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py new file mode 100644 index 000000000..4ff914890 --- /dev/null +++ b/apiserver/plane/api/views/module.py @@ -0,0 +1,109 @@ +# Django Imports +from django.db import IntegrityError +from django.db.models import Prefetch + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet +from plane.api.serializers import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, +) +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Module, ModuleIssue, Project + + +class ModuleViewSet(BaseViewSet): + + model = Module + permission_classes = [ + ProjectEntityPermission, + ] + + def get_serializer_class(self): + return ( + ModuleWriteSerializer + if self.action in ["create", "update", "partial_update"] + else ModuleSerializer + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "module_issues", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ) + ) + ) + + def create(self, request, slug, project_id): + try: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + serializer = ModuleWriteSerializer( + data=request.data, context={"project": project} + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The module name is already taken"}, + status=status.HTTP_410_GONE, + ) + + +class ModuleIssueViewSet(BaseViewSet): + + serializer_class = ModuleIssueSerializer + model = ModuleIssue + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("module") + .select_related("issue") + .distinct() + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 0e3fdfafa..38091aa86 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -36,3 +36,5 @@ from .cycle import Cycle, CycleIssue from .shortcut import Shortcut from .view import View + +from .module import Module, ModuleMember, ModuleIssue diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py new file mode 100644 index 000000000..6ce04d4cb --- /dev/null +++ b/apiserver/plane/db/models/module.py @@ -0,0 +1,88 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Module(ProjectBaseModel): + + name = models.CharField(max_length=255, verbose_name="Module Name") + description = models.TextField(verbose_name="Module Description", blank=True) + description_text = models.JSONField( + verbose_name="Module Description RT", blank=True, null=True + ) + description_html = models.JSONField( + verbose_name="Module Description HTML", blank=True, null=True + ) + start_date = models.DateField(null=True) + target_date = models.DateField(null=True) + status = models.CharField( + choices=( + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="planned", + max_length=20, + ) + lead = models.ForeignKey( + "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True + ) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="module_members", + through="ModuleMember", + through_fields=("module", "member"), + ) + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Module" + verbose_name_plural = "Modules" + db_table = "module" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} {self.start_date} {self.target_date}" + + +class ModuleMember(ProjectBaseModel): + + module = models.ForeignKey("db.Module", on_delete=models.CASCADE) + member = models.ForeignKey("db.User", on_delete=models.CASCADE) + + class Meta: + unique_together = ["module", "member"] + verbose_name = "Module Member" + verbose_name_plural = "Module Members" + db_table = "module_member" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.member}" + + +class ModuleIssue(ProjectBaseModel): + + module = models.ForeignKey( + "db.Module", on_delete=models.CASCADE, related_name="module_issues" + ) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="module_issues" + ) + + class Meta: + unique_together = ["module", "issue"] + verbose_name = "Module Issue" + verbose_name_plural = "Module Issues" + db_table = "module_issues" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.issue.name}" From 78d4aa54678c8c243fbaa3325cee5630a31d0664 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 02:59:17 +0530 Subject: [PATCH 050/104] feat: add icon for project --- apiserver/plane/db/models/project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 1bed4fc8f..d8e46869f 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -53,6 +53,7 @@ class Project(BaseModel): null=True, blank=True, ) + icon = models.CharField(max_length=255, null=True, blank=True) def __str__(self): """Return name of the project""" From 1faebb298b00ad5775906e018e1914abb1b7c030 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 03:04:17 +0530 Subject: [PATCH 051/104] dev: added migration files --- .../db/migrations/0011_auto_20221216_0259.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apiserver/plane/db/migrations/0011_auto_20221216_0259.py diff --git a/apiserver/plane/db/migrations/0011_auto_20221216_0259.py b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py new file mode 100644 index 000000000..3ea3b9d0b --- /dev/null +++ b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py @@ -0,0 +1,110 @@ +# Generated by Django 3.2.14 on 2022-12-15 21:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0010_auto_20221213_2348'), + ] + + operations = [ + migrations.CreateModel( + name='Module', + 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)), + ('name', models.CharField(max_length=255, verbose_name='Module Name')), + ('description', models.TextField(blank=True, verbose_name='Module Description')), + ('description_text', models.JSONField(blank=True, null=True, verbose_name='Module Description RT')), + ('description_html', models.JSONField(blank=True, null=True, verbose_name='Module Description HTML')), + ('start_date', models.DateField(null=True)), + ('target_date', models.DateField(null=True)), + ('status', models.CharField(choices=[('backlog', 'Backlog'), ('planned', 'Planned'), ('in-progress', 'In Progress'), ('paused', 'Paused'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='planned', max_length=20)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_leads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Module', + 'verbose_name_plural': 'Modules', + 'db_table': 'module', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='icon', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name='ModuleMember', + 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='modulemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulemember', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_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_modulemember', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Member', + 'verbose_name_plural': 'Module Members', + 'db_table': 'module_member', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'member')}, + }, + ), + migrations.AddField( + model_name='module', + name='members', + field=models.ManyToManyField(blank=True, related_name='module_members', through='db.ModuleMember', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='module', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_module', to='db.project'), + ), + migrations.AddField( + model_name='module', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='module', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_module', to='db.workspace'), + ), + migrations.CreateModel( + name='ModuleIssue', + 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='moduleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.issue')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_moduleissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_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_moduleissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Issue', + 'verbose_name_plural': 'Module Issues', + 'db_table': 'module_issues', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'issue')}, + }, + ), + migrations.AlterUniqueTogether( + name='module', + unique_together={('name', 'project')}, + ), + ] From bf71be1f75adfbc4238fadc739e0e8bb0c4eaddf Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 03:09:58 +0530 Subject: [PATCH 052/104] feat:create sign up endpoint and remove print logs --- apiserver/plane/api/views/authentication.py | 70 ++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index b1d321f9c..ca8e2df60 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -34,6 +34,74 @@ def get_tokens_for_user(user): ) +class SignUpEndpoint(BaseAPIView): + + permission_classes = (AllowAny,) + + def post(self, request): + try: + + email = request.data.get("email", False) + password = request.data.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + return Response( + {"error": "Both email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError as e: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email=email).first() + + if user is not None: + return Response( + {"error": "Email ID is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create(email=email) + user.set_password(password) + + # settings last actives for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) @@ -104,7 +172,6 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) except Exception as e: - print(e) capture_exception(e) return Response( { @@ -218,7 +285,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, From 1d08f016827abe93c74b7d9f590fc7adb1ea036f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 03:13:30 +0530 Subject: [PATCH 053/104] feat: add extra columns as a response to create and update on issue and issue comments --- apiserver/plane/api/serializers/issue.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 6315564ce..b2dca6625 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -7,6 +7,7 @@ from .user import UserLiteSerializer from .state import StateSerializer from .user import UserLiteSerializer from .project import ProjectSerializer +from .workspace import WorkSpaceSerializer from plane.db.models import ( User, Issue, @@ -19,8 +20,8 @@ from plane.db.models import ( IssueLabel, Label, IssueBlocker, - Cycle, CycleIssue, + Cycle, ) @@ -54,6 +55,9 @@ class IssueStateSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + project_detail = ProjectSerializer(read_only=True, source="project") + workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace") assignees_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -213,6 +217,8 @@ class IssueActivitySerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectSerializer(read_only=True, source="project") class Meta: model = IssueComment @@ -305,7 +311,6 @@ class IssueAssigneeSerializer(BaseSerializer): class CycleBaseSerializer(BaseSerializer): - class Meta: model = Cycle fields = "__all__" @@ -318,6 +323,7 @@ class CycleBaseSerializer(BaseSerializer): "updated_at", ] + class IssueCycleDetailSerializer(BaseSerializer): cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") @@ -335,7 +341,6 @@ class IssueCycleDetailSerializer(BaseSerializer): ] - class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") From 5807632ee45a807dedc53d5e3afa78ed6805440d Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 11:40:24 +0530 Subject: [PATCH 054/104] feat: add field my_issues_prop to store my_issues property --- apiserver/plane/api/views/people.py | 1 - apiserver/plane/db/models/user.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index 1612e0bc7..81118484c 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -52,7 +52,6 @@ class PeopleEndpoint(BaseAPIView): class UserEndpoint(BaseViewSet): serializer_class = UserSerializer model = User - serializers = {} def get_object(self): return self.request.user diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 7efa4be49..1b08c8d69 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -66,6 +66,7 @@ class User(AbstractBaseUser, PermissionsMixin): last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) last_workspace_id = models.UUIDField(null=True) + my_issues_prop = models.JSONField(null=True) USERNAME_FIELD = "email" From b189cae449772c81fd64e86ca7fe704b28f0b4a7 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 11:45:38 +0530 Subject: [PATCH 055/104] build: create procfile for deployments to heroku --- Procfile | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Procfile diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..8b63cf98c --- /dev/null +++ b/Procfile @@ -0,0 +1,3 @@ +web: node apps/app/server.js +backend_web: cd apiserver && gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +worker: cd apiserver && python manage.py rqworker \ No newline at end of file From 676355d673c7f24862f54e1482e5b70ab1d0bf6d Mon Sep 17 00:00:00 2001 From: Dakshesh Jain Date: Fri, 16 Dec 2022 20:26:25 +0530 Subject: [PATCH 056/104] feat: project settings state, filter by cycle refractor: added types for cycle in IIssue, improved common function used for grouping, made custom hook for my issue filter --- .eslintrc.json => apps/app/.eslintrc.json | 0 .../components/command-palette/shortcuts.tsx | 131 ++++++++++------- .../BoardView/state/confirm-state-delete.tsx | 2 +- .../state/create-update-state-inline.tsx | 53 +++++-- .../project/issues/ListView/index.tsx | 3 + .../components/socialbuttons/google-login.tsx | 56 +++---- apps/app/constants/common.ts | 2 +- apps/app/constants/fetch-keys.ts | 2 + apps/app/contexts/theme.context.tsx | 110 +++++++++----- apps/app/lib/hooks/useMyIssueFilter.tsx | 105 ++++++++++++++ apps/app/lib/services/project.service.ts | 16 +- apps/app/lib/services/user.service.ts | 3 +- apps/app/lib/services/workspace.service.ts | 17 ++- apps/app/pages/me/my-issues.tsx | 137 +++++++++++++++--- .../projects/[projectId]/issues/index.tsx | 1 + apps/app/pages/workspace/settings.tsx | 9 +- apps/app/types/issues.d.ts | 17 ++- apps/app/types/users.d.ts | 8 + apps/app/types/workspace.d.ts | 7 +- yarn.lock | 7 +- 20 files changed, 532 insertions(+), 154 deletions(-) rename .eslintrc.json => apps/app/.eslintrc.json (100%) create mode 100644 apps/app/lib/hooks/useMyIssueFilter.tsx diff --git a/.eslintrc.json b/apps/app/.eslintrc.json similarity index 100% rename from .eslintrc.json rename to apps/app/.eslintrc.json diff --git a/apps/app/components/command-palette/shortcuts.tsx b/apps/app/components/command-palette/shortcuts.tsx index 91a8baab3..e4521b25a 100644 --- a/apps/app/components/command-palette/shortcuts.tsx +++ b/apps/app/components/command-palette/shortcuts.tsx @@ -1,15 +1,53 @@ -import React from "react"; +import React, { useState } from "react"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // icons import { XMarkIcon } from "@heroicons/react/20/solid"; +// ui +import { Input } from "ui"; type Props = { isOpen: boolean; setIsOpen: React.Dispatch>; }; +const shortcuts = [ + { + title: "Navigation", + shortcuts: [ + { keys: "ctrl,/", description: "To open navigator" }, + { keys: "↑", description: "Move up" }, + { keys: "↓", description: "Move down" }, + { keys: "←", description: "Move left" }, + { keys: "→", description: "Move right" }, + { keys: "Enter", description: "Select" }, + { keys: "Esc", description: "Close" }, + ], + }, + { + title: "Common", + shortcuts: [ + { keys: "ctrl,p", description: "To create project" }, + { keys: "ctrl,i", description: "To create issue" }, + { keys: "ctrl,q", description: "To create cycle" }, + { keys: "ctrl,h", description: "To open shortcuts guide" }, + { + keys: "ctrl,alt,c", + description: "To copy issue url when on issue detail page.", + }, + ], + }, +]; + const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => { + const [query, setQuery] = useState(""); + + const filteredShortcuts = shortcuts.filter((shortcut) => + shortcut.shortcuts.some((item) => item.description.includes(query.trim())) || query === "" + ? true + : false + ); + return ( @@ -39,7 +77,7 @@ const ShortcutsModal: React.FC = ({ isOpen, setIsOpen }) => {
-
+
= ({ isOpen, setIsOpen }) => { -
- {[ - { - title: "Navigation", - shortcuts: [ - { keys: "ctrl,/", description: "To open navigator" }, - { keys: "↑", description: "Move up" }, - { keys: "↓", description: "Move down" }, - { keys: "←", description: "Move left" }, - { keys: "→", description: "Move right" }, - { keys: "Enter", description: "Select" }, - { keys: "Esc", description: "Close" }, - ], - }, - { - title: "Common", - shortcuts: [ - { keys: "ctrl,p", description: "To create project" }, - { keys: "ctrl,i", description: "To create issue" }, - { keys: "ctrl,q", description: "To create cycle" }, - { keys: "ctrl,h", description: "To open shortcuts guide" }, - { - keys: "ctrl,alt,c", - description: "To copy issue url when on issue detail page.", - }, - ], - }, - ].map(({ title, shortcuts }) => ( -
-

{title}

-
- {shortcuts.map(({ keys, description }, index) => ( -
-

{description}

-
- {keys.split(",").map((key, index) => ( - - - {key} - - {/* {index !== keys.split(",").length - 1 ? ( - + - ) : null} */} - - ))} +
+ setQuery(e.target.value)} + /> +
+
+ {filteredShortcuts.length > 0 ? ( + filteredShortcuts.map(({ title, shortcuts }) => ( +
+

{title}

+
+ {shortcuts.map(({ keys, description }, index) => ( +
+

{description}

+
+ {keys.split(",").map((key, index) => ( + + + {key} + + + ))} +
-
- ))} + ))} +
+ )) + ) : ( +
+

+ No shortcuts found for{" "} + + {`"`} + {query} + {`"`} + +

- ))} + )}
diff --git a/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx index ae1d2d4d1..8b884788c 100644 --- a/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx +++ b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx @@ -61,7 +61,7 @@ const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { useEffect(() => { if (data) setIssuesWithThisStateExist(!!groupedIssues[data.id]); - }, [groupedIssues]); + }, [groupedIssues, data]); return ( diff --git a/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx index 84312a404..10f186bd2 100644 --- a/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx @@ -8,11 +8,12 @@ import { TwitterPicker } from "react-color"; // headless import { Popover, Transition } from "@headlessui/react"; // constants +import { GROUP_CHOICES } from "constants/"; import { STATE_LIST } from "constants/fetch-keys"; // services import stateService from "lib/services/state.service"; // ui -import { Button, Input } from "ui"; +import { Button, Input, Select, Spinner } from "ui"; // types import type { IState } from "types"; @@ -26,6 +27,12 @@ type Props = { export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; +const defaultValues: Partial = { + name: "", + color: "#000000", + group: "backlog", +}; + export const CreateUpdateStateInline: React.FC = ({ workspaceSlug, projectId, @@ -36,17 +43,13 @@ export const CreateUpdateStateInline: React.FC = ({ const { register, handleSubmit, - formState: { errors }, + formState: { errors, isSubmitting }, setError, watch, reset, control, } = useForm({ - defaultValues: { - name: "", - color: "#000000", - group: "backlog", - }, + defaultValues, }); const handleClose = () => { @@ -55,13 +58,13 @@ export const CreateUpdateStateInline: React.FC = ({ }; const onSubmit = async (formData: IState) => { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !projectId || isSubmitting) return; const payload: IState = { ...formData, }; if (!data) { await stateService - .createState(workspaceSlug, projectId, { ...payload, group: selectedGroup }) + .createState(workspaceSlug, projectId, { ...payload }) .then((res) => { mutate(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false); handleClose(); @@ -77,7 +80,6 @@ export const CreateUpdateStateInline: React.FC = ({ await stateService .updateState(workspaceSlug, projectId, data.id, { ...payload, - group: selectedGroup ?? "backlog", }) .then((res) => { mutate( @@ -108,7 +110,15 @@ export const CreateUpdateStateInline: React.FC = ({ useEffect(() => { if (data === null) return; reset(data); - }, [data]); + }, [data, reset]); + + useEffect(() => { + if (!data) + reset({ + ...defaultValues, + group: selectedGroup ?? "backlog", + }); + }, [selectedGroup, data, reset]); return (
@@ -160,11 +170,26 @@ export const CreateUpdateStateInline: React.FC = ({ register={register} placeholder="Enter state name" validations={{ - required: "Name is required", + required: true, }} error={errors.name} autoComplete="off" /> + {data && ( + = ({ -
); diff --git a/apps/app/components/project/issues/ListView/index.tsx b/apps/app/components/project/issues/ListView/index.tsx index a95c83606..b5dd17336 100644 --- a/apps/app/components/project/issues/ListView/index.tsx +++ b/apps/app/components/project/issues/ListView/index.tsx @@ -93,6 +93,9 @@ const ListView: React.FC = ({

{singleGroup === null || singleGroup === "null" ? selectedGroup === "priority" && "No priority" + : selectedGroup === "created_by" + ? people?.find((p) => p.member.id === singleGroup)?.member + ?.first_name ?? "Loading..." : addSpaceIfCamelCase(singleGroup)}

) : ( diff --git a/apps/app/components/socialbuttons/google-login.tsx b/apps/app/components/socialbuttons/google-login.tsx index 51979cc81..62d9402c7 100644 --- a/apps/app/components/socialbuttons/google-login.tsx +++ b/apps/app/components/socialbuttons/google-login.tsx @@ -1,4 +1,4 @@ -import { FC, CSSProperties } from "react"; +import { FC, CSSProperties, useEffect, useRef, useCallback } from "react"; // next import Script from "next/script"; @@ -10,32 +10,38 @@ export interface IGoogleLoginButton { } export const GoogleLoginButton: FC = (props) => { + const googleSignInButton = useRef(null); + + const loadScript = useCallback(() => { + if (!googleSignInButton.current) return; + window?.google?.accounts.id.initialize({ + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", + callback: props.onSuccess as any, + }); + window?.google?.accounts.id.renderButton( + googleSignInButton.current, + { + type: "standard", + theme: "outline", + size: "large", + logo_alignment: "center", + width: document.getElementById("googleSignInButton")?.offsetWidth, + text: "continue_with", + } as GsiButtonConfiguration // customization attributes + ); + window?.google?.accounts.id.prompt(); // also display the One Tap dialog + }, [props.onSuccess]); + + useEffect(() => { + if (window?.google?.accounts?.id) { + loadScript(); + } + }, [loadScript]); + return ( <> -