From 26ec1e8c155b98e8a220fe1100d47ab5d783b948 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 30 Nov 2022 02:47:42 +0530 Subject: [PATCH] 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