build: merge frontend and backend into a single repo

This commit is contained in:
pablohashescobar 2022-11-30 02:47:42 +05:30
parent 10ce333e6f
commit 26ec1e8c15
126 changed files with 8280 additions and 1 deletions

24
.gitignore vendored
View File

@ -39,4 +39,26 @@ yarn-error.log*
.vercel
# Turborepo
.turbo
.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

17
apiserver/manage.py Normal file
View File

@ -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)

View File

View File

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
name = 'plane.analytics'

View File

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.api"

View File

@ -0,0 +1,2 @@
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission

View File

@ -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()

View File

@ -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()

View File

@ -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,
)

View File

@ -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",
]

View File

@ -0,0 +1,5 @@
from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
id = serializers.PrimaryKeyRelatedField(read_only=True)

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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__"

View File

@ -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__"

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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)

584
apiserver/plane/api/urls.py Normal file
View File

@ -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/<uidb64>/<token>/",
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/<uuid:pk>/",
UserWorkspaceInvitationEndpoint.as_view(
{
"get": "retrieve",
}
),
name="workspace",
),
# user join workspace
path(
"users/me/invitations/workspaces/<str:slug>/<uuid:pk>/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/<str:slug>/",
WorkSpaceViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="workspace",
),
path(
"workspaces/<str:slug>/invite/",
InviteWorkspaceEndpoint.as_view(),
name="workspace",
),
path(
"workspaces/<str:slug>/invitations/",
WorkspaceInvitationsViewset.as_view({"get": "list"}),
name="workspace",
),
path(
"workspaces/<str:slug>/invitations/<uuid:pk>/",
WorkspaceInvitationsViewset.as_view(
{
"delete": "destroy",
"get": "retrieve",
"get": "retrieve",
}
),
name="workspace",
),
path(
"workspaces/<str:slug>/members/",
WorkSpaceMemberViewSet.as_view({"get": "list"}),
name="workspace",
),
path(
"workspaces/<str:slug>/members/<uuid:pk>/",
WorkSpaceMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
}
),
name="workspace",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace",
),
path(
"workspaces/<str:slug>/teams/<uuid:pk>/",
TeamMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
}
),
name="workspace",
),
## End Workspaces ##
# Projects
path(
"workspaces/<str:slug>/projects/",
ProjectViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project",
),
path(
"workspaces/<str:slug>/project-identifiers/",
ProjectIdentifierEndpoint.as_view(),
name="project-identifiers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invite/",
InviteProjectEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberViewSet.as_view({"get": "list"}),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
ProjectMemberViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
AddTeamToProjectEndpoint.as_view(),
name="projects",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invitations/",
ProjectMemberInvitationsViewset.as_view({"get": "list"}),
name="workspace",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/invitations/<uuid:pk>/",
ProjectMemberInvitationsViewset.as_view(
{
"get": "retrieve",
"delete": "destroy",
}
),
name="project",
),
# States
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/",
StateViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/",
StateViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-state",
),
# End States ##
# Shortcuts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/",
ShortCutViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-shortcut",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/shortcuts/<uuid:pk>/",
ShortCutViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-shortcut",
),
## End Shortcuts
# Views
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
ViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
ViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-view",
),
## End Views
## Cycles
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/",
CycleViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
CycleIssueViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/bulk-assign-issues/",
BulkAssignIssuesToCycleEndpoint.as_view(),
name="bulk-assign-cycle-issues",
),
## End Cycles
# Issue
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue",
),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
LabelViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-labels",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-delete-issues/",
BulkDeleteIssuesEndpoint.as_view(),
),
## End Issues
## Issue Activity
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/history/",
IssueActivityEndpoint.as_view(),
name="project-issue-history",
),
## Issue Activity
## IssueComments
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-comment",
),
## End IssueComments
## Roadmap
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/",
TimeLineIssueViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/roadmaps/<uuid:pk>/",
TimeLineIssueViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-roadmap",
),
## End Roadmap
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
IssuePropertyViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
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/<int:pk>/all/",
# IssueViewSet.as_view({"get": "list_issue_history_comments"}),
# name="Issue history and comments",
# ),
]

View File

@ -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,
)

View File

@ -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)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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

View File

@ -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
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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()
)

View File

@ -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()
)

View File

@ -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()
)

View File

@ -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")
)

View File

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BgtasksConfig(AppConfig):
name = 'plane.bgtasks'

View File

View File

@ -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 <team@mailer.plane.so>"
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

View File

@ -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 <team@mailer.plane.so>"
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

View File

@ -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 <team@mailer.plane.so>"
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

View File

@ -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 <team@mailer.plane.so>"
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

View File

@ -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 <team@mailer.plane.so>"
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

View File

View File

@ -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)

View File

@ -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,
)

View File

@ -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')},
),
]

View File

@ -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'),
),
]

View File

@ -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')},
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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}"

View File

@ -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
)

View File

@ -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",)

View File

@ -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}>"

View File

@ -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}>"

View File

@ -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)

View File

@ -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 <team@mailer.plane.so>"
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

View File

@ -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}>"

View File

@ -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",)

View File

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class Middleware(AppConfig):
name = 'plane.middleware'

View File

@ -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

View File

View File

@ -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),
}

View File

@ -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"

View File

@ -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")

View File

@ -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

View File

@ -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")

View File

@ -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"

View File

View File

View File

View File

@ -0,0 +1 @@
from .api import *

View File

View File

@ -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)

View File

@ -0,0 +1 @@
# TODO: Tests for File Asset Uploads

View File

@ -0,0 +1 @@
#TODO: Tests for ChangePassword and other Endpoints

View File

@ -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",
)

View File

@ -0,0 +1 @@
# TODO: Write Test for Cycle Endpoints

View File

@ -0,0 +1 @@
# TODO: Write Test for Issue Endpoints

View File

@ -0,0 +1 @@
#TODO: Tests for OAuth Authentication Endpoint

View File

@ -0,0 +1 @@
# TODO: Write Test for people Endpoint

View File

@ -0,0 +1 @@
# TODO: Write Tests for project endpoints

View File

@ -0,0 +1 @@
# TODO: Write Test for shortcuts

View File

@ -0,0 +1 @@
# TODO: Wrote test for state endpoints

View File

@ -0,0 +1 @@
# TODO: Write test for view endpoints

View File

@ -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)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.tests"

29
apiserver/plane/urls.py Normal file
View File

@ -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

Some files were not shown because too many files have changed in this diff Show More