From 26ec1e8c155b98e8a220fe1100d47ab5d783b948 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 30 Nov 2022 02:47:42 +0530 Subject: [PATCH 01/46] 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 02/46] 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 03/46] 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 04/46] 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 05/46] 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 06/46] 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 07/46] 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 08/46] 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 09/46] 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 9afe196024cb06d9cffe98993ab99e9fb644b4ea Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 9 Dec 2022 21:09:54 +0530 Subject: [PATCH 10/46] 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 11/46] 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 12/46] 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 db10c884e8be077e92ff30514d8c3e36f715d958 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 13:27:23 +0530 Subject: [PATCH 13/46] 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 4414a71331754cacf44c651e85b8b3683efdfdb6 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 13 Dec 2022 20:47:49 +0530 Subject: [PATCH 14/46] 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 15/46] 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 16/46] 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 17/46] 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 18/46] 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 19/46] 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 20/46] 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 21/46] 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 22/46] 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 23/46] 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 24/46] 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 25/46] 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 26/46] 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 27/46] 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 28/46] 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 29/46] 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 30/46] 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 31/46] 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 32/46] 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 33/46] 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 34/46] 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 35/46] 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 36/46] 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 37/46] 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 38/46] 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 f9b8ee0d500f84990704c322937d1b5fe6d07bef Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 15 Dec 2022 02:37:26 +0530 Subject: [PATCH 39/46] 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 40/46] 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 41/46] 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 fa611b06312460346f47f4c45940a933641a1488 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 16 Dec 2022 02:57:50 +0530 Subject: [PATCH 42/46] 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 43/46] 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 44/46] 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 45/46] 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 46/46] 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")