diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..45ff21c4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +*.pyc +.env +venv +node_modules +npm-debug.log \ No newline at end of file diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml new file mode 100644 index 000000000..5780c9f63 --- /dev/null +++ b/.github/workflows/test_runner.yml @@ -0,0 +1,53 @@ +name: Plane Tests + +on: + push: + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: github_actions + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + redis: + image: redis:latest + env: + REDIS_HOST: localhost + REDIS_PORT: 6379 + ports: + - 6379:6379 + options: --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: psycopg2 prerequisites + run: sudo apt-get install libpq-dev + - name: Install dependencies + working-directory: ./apiserver + run: | + python -m pip install --upgrade pip + pip install -r requirements/test.txt + - name: Run Tests + working-directory: ./apiserver + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} + run: coverage run --source='.' manage.py test --settings=plane.settings.test diff --git a/.gitignore b/.gitignore index dd512696d..ad72521ff 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,26 @@ yarn-error.log* .vercel # Turborepo -.turbo \ No newline at end of file +.turbo + +## Django ## +venv +*.pyc +staticfiles +mediafiles +.env +.DS_Store + +node_modules/ +assets/dist/ +npm-debug.log +yarn-error.log + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +package-lock.json +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..abb8505cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,138 @@ +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +RUN apk add curl + +COPY ./apps ./apps +COPY ./package.json ./package.json +COPY ./.eslintrc.json ./.eslintrc.json +COPY ./yarn.lock ./yarn.lock + +RUN yarn global add turbo +RUN turbo prune --scope=app --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM node:18-alpine AS installer + +RUN apk add --no-cache libc6-compat +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +RUN yarn turbo run build --filter=app... + +FROM python:3.8.14-alpine3.16 AS runner + +ENV SECRET_KEY ${SECRET_KEY} +ENV DATABASE_URL ${DATABASE_URL} +ENV REDIS_URL ${REDIS_URL} +ENV EMAIL_HOST ${EMAIL_HOST} +ENV EMAIL_HOST_USER ${EMAIL_HOST_USER} +ENV EMAIL_HOST_PASSWORD ${EMAIL_HOST_PASSWORD} + +ENV AWS_REGION ${AWS_REGION} +ENV AWS_ACCESS_KEY_ID ${AWS_ACCESS_KEY_ID} +ENV AWS_SECRET_ACCESS_KEY ${AWS_SECRET_ACCESS_KEY} +ENV AWS_S3_BUCKET_NAME ${AWS_S3_BUCKET_NAME} + + +ENV SENTRY_DSN ${SENTRY_DSN} +ENV WEB_URL ${WEB_URL} + +ENV DISABLE_COLLECTSTATIC ${DISABLE_COLLECTSTATIC} + +ENV GITHUB_CLIENT_SECRET ${GITHUB_CLIENT_SECRET} +ENV NEXT_PUBLIC_GITHUB_ID ${NEXT_PUBLIC_GITHUB_ID} +ENV NEXT_PUBLIC_GOOGLE_CLIENTID ${NEXT_PUBLIC_GOOGLE_CLIENTID} +ENV NEXT_PUBLIC_API_BASE_URL ${NEXT_PUBLIC_API_BASE_URL} + +# Frontend + +RUN apk --update --no-cache add \ + "libpq~=14" \ + "libxslt~=1.1" \ + "nodejs-current~=18" \ + "xmlsec~=1.2" + +WORKDIR /app + +# Don't run production as root +RUN addgroup -S plane && \ + adduser -S captain -G plane + +USER captain + +COPY --from=installer /app/apps/app/next.config.js . +COPY --from=installer /app/apps/app/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ +COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static + +EXPOSE 3000 + +# Backend + +USER root + + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + + +COPY ./apiserver/requirements.txt ./ +COPY ./apiserver/requirements ./requirements +RUN apk add libffi-dev +RUN apk --update --no-cache --virtual .build-deps add \ + "bash~=5.1" \ + "g++~=11.2" \ + "gcc~=11.2" \ + "cargo~=1.60" \ + "git~=2" \ + "make~=4.3" \ + "postgresql13-dev~=13" \ + "libc-dev" \ + "linux-headers" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps + + +RUN chown captain.plane /app + +# Add in Django deps and generate Django's static files +COPY ./apiserver/manage.py manage.py +COPY ./apiserver/plane plane/ +COPY ./apiserver/templates templates/ + +COPY ./apiserver/gunicorn.config.py ./ +USER root +RUN apk --update --no-cache add "bash~=5.1" +COPY ./bin ./bin/ +USER captain + +# Expose container port and run entry point script +EXPOSE 8000 + +USER root + +RUN apk --update add supervisor + +ADD /supervisor /src/supervisor + +CMD ["supervisord","-c","/src/supervisor/service_script.conf"] \ No newline at end of file diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api new file mode 100644 index 000000000..f2aa59e51 --- /dev/null +++ b/apiserver/Dockerfile.api @@ -0,0 +1,57 @@ +FROM python:3.8.14-alpine3.16 AS backend + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /code + +RUN apk --update --no-cache add \ + "libpq~=14" \ + "libxslt~=1.1" \ + "nodejs-current~=18" \ + "xmlsec~=1.2" + +COPY requirements.txt ./ +COPY requirements ./requirements +RUN apk add libffi-dev +RUN apk --update --no-cache --virtual .build-deps add \ + "bash~=5.1" \ + "g++~=11.2" \ + "gcc~=11.2" \ + "cargo~=1.60" \ + "git~=2" \ + "make~=4.3" \ + "postgresql13-dev~=13" \ + "libc-dev" \ + "linux-headers" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps + + +RUN addgroup -S plane && \ + adduser -S captain -G plane + +RUN chown captain.plane /code + +USER captain + +# Add in Django deps and generate Django's static files +COPY manage.py manage.py +COPY plane plane/ +COPY templates templates/ + +COPY gunicorn.config.py ./ +USER root +RUN apk --update --no-cache add "bash~=5.1" +COPY ./bin ./bin/ +USER captain + +# Expose container port and run entry point script +EXPOSE 8000 + +CMD [ "./bin/takeoff" ] + diff --git a/apiserver/Procfile b/apiserver/Procfile new file mode 100644 index 000000000..f5b7e7897 --- /dev/null +++ b/apiserver/Procfile @@ -0,0 +1,2 @@ +web: gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +worker: python manage.py rqworker \ No newline at end of file diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff new file mode 100755 index 000000000..b76a9fd3b --- /dev/null +++ b/apiserver/bin/takeoff @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +python manage.py wait_for_db +python manage.py migrate +exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/worker b/apiserver/bin/worker new file mode 100755 index 000000000..25a947613 --- /dev/null +++ b/apiserver/bin/worker @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db +python manage.py migrate +python manage.py rqworker \ No newline at end of file diff --git a/apiserver/gunicorn.config.py b/apiserver/gunicorn.config.py new file mode 100644 index 000000000..67205b5ec --- /dev/null +++ b/apiserver/gunicorn.config.py @@ -0,0 +1,6 @@ +from psycogreen.gevent import patch_psycopg + + +def post_fork(server, worker): + patch_psycopg() + worker.log.info("Made Psycopg2 Green") \ No newline at end of file diff --git a/apiserver/manage.py b/apiserver/manage.py new file mode 100644 index 000000000..837297219 --- /dev/null +++ b/apiserver/manage.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', + 'plane.settings.production') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/apiserver/plane/__init__.py b/apiserver/plane/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/analytics/__init__.py b/apiserver/plane/analytics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/analytics/apps.py b/apiserver/plane/analytics/apps.py new file mode 100644 index 000000000..353779983 --- /dev/null +++ b/apiserver/plane/analytics/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + name = 'plane.analytics' diff --git a/apiserver/plane/api/__init__.py b/apiserver/plane/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py new file mode 100644 index 000000000..6ba36e7e5 --- /dev/null +++ b/apiserver/plane/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.api" diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py new file mode 100644 index 000000000..71ec4815d --- /dev/null +++ b/apiserver/plane/api/permissions/__init__.py @@ -0,0 +1,2 @@ +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission +from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py new file mode 100644 index 000000000..019496cda --- /dev/null +++ b/apiserver/plane/api/permissions/project.py @@ -0,0 +1,63 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module import +from plane.db.models import WorkspaceMember, ProjectMember + + +class ProjectBasePermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role=20 + ).exists() + + +class ProjectMemberPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + if request.method == "POST": + return WorkspaceMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + ## Only Project Admins can update project attributes + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() + + +class ProjectEntityPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return True + ## Only workspace owners or admins can create the projects + + return ProjectMember.objects.filter( + workspace=view.workspace, member=request.user, role__in=[15, 20] + ).exists() diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py new file mode 100644 index 000000000..510d87ce2 --- /dev/null +++ b/apiserver/plane/api/permissions/workspace.py @@ -0,0 +1,43 @@ +# Third Party imports +from rest_framework.permissions import BasePermission, SAFE_METHODS + +# Module imports +from plane.db.models import WorkspaceMember, ProjectMember + + +# TODO: Move the below logic to python match - python v3.10 +class WorkSpaceBasePermission(BasePermission): + def has_permission(self, request, view): + # allow anyone to create a workspace + if request.user.is_anonymous: + return False + + if request.method == "POST": + return True + + ## Safe Methods + if request.method in SAFE_METHODS: + return True + + # allow only admins and owners to update the workspace settings + if request.method in ["PUT", "PATCH"]: + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role__in=[15, 20] + ).exists() + + # allow only owner to delete the workspace + if request.method == "DELETE": + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role=20 + ).exists() + + +class WorkSpaceAdminPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace=view.workspace, role__in=[15, 20] + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py new file mode 100644 index 000000000..e2d474901 --- /dev/null +++ b/apiserver/plane/api/serializers/__init__.py @@ -0,0 +1,42 @@ +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, +) + +from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/asset.py b/apiserver/plane/api/serializers/asset.py new file mode 100644 index 000000000..136e2264b --- /dev/null +++ b/apiserver/plane/api/serializers/asset.py @@ -0,0 +1,14 @@ +from .base import BaseSerializer +from plane.db.models import FileAsset + + +class FileAssetSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py new file mode 100644 index 000000000..0c6bba468 --- /dev/null +++ b/apiserver/plane/api/serializers/base.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py new file mode 100644 index 000000000..4e125bfae --- /dev/null +++ b/apiserver/plane/api/serializers/cycle.py @@ -0,0 +1,33 @@ +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .issue import IssueStateSerializer +from plane.db.models import Cycle, CycleIssue + + +class CycleSerializer(BaseSerializer): + + owned_by = UserLiteSerializer(read_only=True) + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] + + +class CycleIssueSerializer(BaseSerializer): + + issue_detail = IssueStateSerializer(read_only=True, source="issue") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "cycle", + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py new file mode 100644 index 000000000..06795fd77 --- /dev/null +++ b/apiserver/plane/api/serializers/issue.py @@ -0,0 +1,439 @@ +# 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 .workspace import WorkSpaceSerializer +from plane.db.models import ( + User, + Issue, + IssueActivity, + IssueComment, + TimelineIssue, + IssueProperty, + IssueBlocker, + IssueAssignee, + IssueLabel, + Label, + IssueBlocker, + CycleIssue, + Cycle, + Module, + ModuleIssue, +) + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "priority", + "start_date", + "target_date", + "sequence_id", + ] + + +# 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") + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + project_detail = ProjectSerializer(read_only=True, source="project") + workspace_detail = WorkSpaceSerializer(read_only=True, source="workspace") + + assignees_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + 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, + ) + blocks_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Issue.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) + blocks = validated_data.pop("blocks_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, + ) + + if blocks is not None: + IssueBlocker.objects.bulk_create( + [ + IssueBlocker( + block=block, + blocked_by=issue, + project=project, + workspace=project.workspace, + created_by=issue.created_by, + updated_by=issue.updated_by, + ) + for block in blocks + ], + 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) + blocks = validated_data.pop("blocks_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, + ) + + if blocks is not None: + IssueBlocker.objects.filter(blocked_by=instance).delete() + IssueBlocker.objects.bulk_create( + [ + IssueBlocker( + block=block, + blocked_by=instance, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for block in blocks + ], + 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") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectSerializer(read_only=True, source="project") + + 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 CycleBaseSerializer(BaseSerializer): + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueCycleDetailSerializer(BaseSerializer): + + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleBaseSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueModuleDetailSerializer(BaseSerializer): + + module_detail = ModuleBaseSerializer(read_only=True, source="module") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectSerializer(read_only=True, source="project") + state_detail = StateSerializer(read_only=True, source="state") + 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) + issue_cycle = IssueCycleDetailSerializer(read_only=True) + issue_module = IssueModuleDetailSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py new file mode 100644 index 000000000..989802c8b --- /dev/null +++ b/apiserver/plane/api/serializers/module.py @@ -0,0 +1,134 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .project import ProjectSerializer +from .issue import IssueStateSerializer + +from plane.db.models import User, Module, ModuleMember, ModuleIssue + + +class ModuleWriteSerializer(BaseSerializer): + + members_list = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data): + + members = validated_data.pop("members_list", None) + + project = self.context["project"] + + module = Module.objects.create(**validated_data, project=project) + + if members is not None: + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=module, + member=member, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True, + ) + + return module + + def update(self, instance, validated_data): + + members = validated_data.pop("members_list", None) + if members is not None: + ModuleIssue.objects.filter(module=instance).delete() + ModuleMember.objects.bulk_create( + [ + ModuleMember( + module=instance, + member=member, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + ) + for member in members + ], + batch_size=10, + ignore_conflicts=True + ) + + return super().update(instance, validated_data) + + +class ModuleFlatSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleIssueSerializer(BaseSerializer): + + module_detail = ModuleFlatSerializer(read_only=True, source="module") + issue_detail = IssueStateSerializer(read_only=True, source="issue") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleSerializer(BaseSerializer): + + project_detail = ProjectSerializer(read_only=True, source="project") + lead_detail = UserLiteSerializer(read_only=True, source="lead") + members_detail = UserLiteSerializer(read_only=True, many=True, source="members") + module_issues = ModuleIssueSerializer(read_only=True, many=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/people.py b/apiserver/plane/api/serializers/people.py new file mode 100644 index 000000000..b8b59416c --- /dev/null +++ b/apiserver/plane/api/serializers/people.py @@ -0,0 +1,57 @@ +from rest_framework.serializers import ( + ModelSerializer, + Serializer, + CharField, + SerializerMethodField, +) +from rest_framework.authtoken.models import Token +from rest_framework_simplejwt.tokens import RefreshToken + + +from plane.db.models import User + + +class UserSerializer(ModelSerializer): + class Meta: + model = User + fields = "__all__" + extra_kwargs = {"password": {"write_only": True}} + + +class ChangePasswordSerializer(Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = CharField(required=True) + new_password = CharField(required=True) + + +class ResetPasswordSerializer(Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + new_password = CharField(required=True) + confirm_password = CharField(required=True) + + +class TokenSerializer(ModelSerializer): + + user = UserSerializer() + access_token = SerializerMethodField() + refresh_token = SerializerMethodField() + + def get_access_token(self, obj): + refresh_token = RefreshToken.for_user(obj.user) + return str(refresh_token.access_token) + + def get_refresh_token(self, obj): + refresh_token = RefreshToken.for_user(obj.user) + return str(refresh_token) + + class Meta: + model = Token + fields = "__all__" diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py new file mode 100644 index 000000000..cdc9adf36 --- /dev/null +++ b/apiserver/plane/api/serializers/project.py @@ -0,0 +1,110 @@ +# 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, workspace_id=self.context["workspace_id"] + ).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + project = Project.objects.create( + **validated_data, workspace_id=self.context["workspace_id"] + ) + _ = ProjectIdentifier.objects.create( + name=project.identifier, + project=project, + workspace_id=self.context["workspace_id"], + ) + 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, workspace_id=instance.workspace_id + ).first() + + if project_identifier is None: + project = super().update(instance, validated_data) + _ = ProjectIdentifier.objects.update(name=identifier, project=project) + return project + + # If found check if the project_id to be updated and identifier project id is same + if project_identifier.project_id == instance.id: + # If same pass update + project = super().update(instance, validated_data) + return project + + # If not same fail update + raise serializers.ValidationError(detail="Project Identifier is already taken") + + +class ProjectDetailSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + default_assignee = UserLiteSerializer(read_only=True) + project_lead = UserLiteSerializer(read_only=True) + + class Meta: + model = Project + fields = "__all__" + + +class ProjectMemberSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + project = ProjectSerializer(read_only=True) + member = UserLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberInviteSerializer(BaseSerializer): + + project = ProjectSerializer(read_only=True) + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = ProjectMemberInvite + fields = "__all__" + + +class ProjectIdentifierSerializer(BaseSerializer): + class Meta: + model = ProjectIdentifier + fields = "__all__" diff --git a/apiserver/plane/api/serializers/shortcut.py b/apiserver/plane/api/serializers/shortcut.py new file mode 100644 index 000000000..18c2bd049 --- /dev/null +++ b/apiserver/plane/api/serializers/shortcut.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import Shortcut + + +class ShortCutSerializer(BaseSerializer): + class Meta: + model = Shortcut + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py new file mode 100644 index 000000000..6917f8d69 --- /dev/null +++ b/apiserver/plane/api/serializers/state.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import State + + +class StateSerializer(BaseSerializer): + class Meta: + model = State + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py new file mode 100644 index 000000000..808991ddc --- /dev/null +++ b/apiserver/plane/api/serializers/user.py @@ -0,0 +1,40 @@ +# Module import +from .base import BaseSerializer +from plane.db.models import User + + +class UserSerializer(BaseSerializer): + class Meta: + model = User + fields = "__all__" + read_only_fields = [ + "id", + "created_at", + "updated_at", + "is_superuser", + "is_staff", + "last_active", + "last_login_time", + "last_logout_time", + "last_login_ip", + "last_logout_ip", + "last_login_uagent", + "token_updated_at", + "is_onboarded", + ] + extra_kwargs = {"password": {"write_only": True}} + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "email", + "avatar", + ] + read_only_fields = [ + "id", + ] diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py new file mode 100644 index 000000000..23ac768ef --- /dev/null +++ b/apiserver/plane/api/serializers/view.py @@ -0,0 +1,14 @@ +# Module imports +from .base import BaseSerializer + +from plane.db.models import View + + +class ViewSerializer(BaseSerializer): + class Meta: + model = View + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py new file mode 100644 index 000000000..15f9b1dbf --- /dev/null +++ b/apiserver/plane/api/serializers/workspace.py @@ -0,0 +1,100 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer + +from plane.db.models import User, Workspace, WorkspaceMember, Team, TeamMember +from plane.db.models import Workspace, WorkspaceMember, Team, WorkspaceMemberInvite + + +class WorkSpaceSerializer(BaseSerializer): + + owner = UserLiteSerializer(read_only=True) + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "slug", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + ] + extra_kwargs = { + "slug": { + "required": False, + }, + } + + +class WorkSpaceMemberSerializer(BaseSerializer): + + member = UserLiteSerializer(read_only=True) + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkSpaceMemberInviteSerializer(BaseSerializer): + + workspace = WorkSpaceSerializer(read_only=True) + + class Meta: + model = WorkspaceMemberInvite + fields = "__all__" + + +class TeamSerializer(BaseSerializer): + + members_detail = UserLiteSerializer(read_only=True, source="members", many=True) + members = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Team + fields = "__all__" + read_only_fields = [ + "workspace", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def create(self, validated_data, **kwargs): + if "members" in validated_data: + members = validated_data.pop("members") + workspace = self.context["workspace"] + team = Team.objects.create(**validated_data, workspace=workspace) + team_members = [ + TeamMember(member=member, team=team, workspace=workspace) + for member in members + ] + TeamMember.objects.bulk_create(team_members, batch_size=10) + return team + else: + team = Team.objects.create(**validated_data) + return team + + def update(self, instance, validated_data): + if "members" in validated_data: + members = validated_data.pop("members") + TeamMember.objects.filter(team=instance).delete() + team_members = [ + TeamMember(member=member, team=instance, workspace=instance.workspace) + for member in members + ] + TeamMember.objects.bulk_create(team_members, batch_size=10) + return super().update(instance, validated_data) + else: + return super().update(instance, validated_data) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py new file mode 100644 index 000000000..b04f9dc3a --- /dev/null +++ b/apiserver/plane/api/urls.py @@ -0,0 +1,644 @@ +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, + WorkSpaceIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + TeamMemberViewSet, + TimeLineIssueViewSet, + CycleIssueViewSet, + IssuePropertyViewSet, + UpdateUserOnBoardedEndpoint, + UserWorkspaceInvitationEndpoint, + UserProjectInvitationsViewset, + ProjectIdentifierEndpoint, + LabelViewSet, + AddMemberToProjectEndpoint, + ProjectJoinEndpoint, + BulkDeleteIssuesEndpoint, + ProjectUserViewsEndpoint, + ModuleViewSet, + ModuleIssueViewSet, + UserLastProjectWithWorkspaceEndpoint, + UserWorkSpaceIssues, + ProjectMemberUserEndpoint, +) + +from plane.api.views.project import AddTeamToProjectEndpoint + + +urlpatterns = [ + # Social Auth + path("social-auth/", OauthEndpoint.as_view(), name="oauth"), + # Auth + path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), + path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), + # Magic Sign In/Up + path( + "magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate" + ), + path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), + # Email verification + path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), + path( + "request-email-verify/", + RequestEmailVerificationEndpoint.as_view(), + name="request-reset-email", + ), + # Password Manipulation + path( + "password-reset///", + ResetPasswordEndpoint.as_view(), + name="password-reset", + ), + path( + "forgot-password/", + ForgotPasswordEndpoint.as_view(), + name="forgot-password", + ), + # List Users + path("users/", PeopleEndpoint.as_view()), + # User Profile + path( + "users/me/", + UserEndpoint.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="users", + ), + path( + "users/me/change-password/", + ChangePasswordEndpoint.as_view(), + name="change-password", + ), + path( + "users/me/onboard/", + UpdateUserOnBoardedEndpoint.as_view(), + name="change-password", + ), + # user workspaces + path( + "users/me/workspaces/", + UserWorkSpacesEndpoint.as_view(), + name="user-workspace", + ), + # user workspace invitations + path( + "users/me/invitations/workspaces/", + UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}), + name="user-workspace-invitations", + ), + # user workspace invitation + path( + "users/me/invitations//", + UserWorkspaceInvitationEndpoint.as_view( + { + "get": "retrieve", + } + ), + name="workspace", + ), + # user join workspace + path( + "users/me/invitations/workspaces///join/", + JoinWorkspaceEndpoint.as_view(), + name="user-join-workspace", + ), + # user project invitations + path( + "users/me/invitations/projects/", + UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), + name="user-project-invitaions", + ), + ## Workspaces ## + path( + "workspaces/", + WorkSpaceViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace", + ), + path( + "workspaces//", + WorkSpaceViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace", + ), + path( + "workspaces//invite/", + InviteWorkspaceEndpoint.as_view(), + name="workspace", + ), + path( + "workspaces//invitations/", + WorkspaceInvitationsViewset.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//invitations//", + WorkspaceInvitationsViewset.as_view( + { + "delete": "destroy", + "get": "retrieve", + "get": "retrieve", + } + ), + name="workspace", + ), + path( + "workspaces//members/", + WorkSpaceMemberViewSet.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//members//", + WorkSpaceMemberViewSet.as_view( + { + "put": "update", + "patch": "partial_update", + "delete": "destroy", + "get": "retrieve", + } + ), + name="workspace", + ), + path( + "workspaces//teams/", + TeamMemberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace", + ), + path( + "workspaces//teams//", + TeamMemberViewSet.as_view( + { + "put": "update", + "patch": "partial_update", + "delete": "destroy", + "get": "retrieve", + } + ), + name="workspace", + ), + path( + "users/last-visited-workspace/", + UserLastProjectWithWorkspaceEndpoint.as_view(), + name="workspace-project-details", + ), + ## End Workspaces ## + # Projects + path( + "workspaces//projects/", + ProjectViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project", + ), + path( + "workspaces//projects//", + ProjectViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//project-identifiers/", + ProjectIdentifierEndpoint.as_view(), + name="project-identifiers", + ), + path( + "workspaces//projects//invite/", + InviteProjectEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects//members/", + ProjectMemberViewSet.as_view({"get": "list"}), + name="project", + ), + path( + "workspaces//projects//members//", + ProjectMemberViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//projects//members/add/", + AddMemberToProjectEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects/join/", + ProjectJoinEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects//team-invite/", + AddTeamToProjectEndpoint.as_view(), + name="projects", + ), + path( + "workspaces//projects//invitations/", + ProjectMemberInvitationsViewset.as_view({"get": "list"}), + name="workspace", + ), + path( + "workspaces//projects//invitations//", + ProjectMemberInvitationsViewset.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project", + ), + path( + "workspaces//projects//project-views/", + ProjectUserViewsEndpoint.as_view(), + name="project-view", + ), + path( + "workspaces//projects//project-members/me/", + ProjectMemberUserEndpoint.as_view(), + name="project-view", + ), + # End Projects + # States + path( + "workspaces//projects//states/", + StateViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-states", + ), + path( + "workspaces//projects//states//", + StateViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-state", + ), + # End States ## + # Shortcuts + path( + "workspaces//projects//shortcuts/", + ShortCutViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-shortcut", + ), + path( + "workspaces//projects//shortcuts//", + ShortCutViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-shortcut", + ), + ## End Shortcuts + # Views + path( + "workspaces//projects//views/", + ViewViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-view", + ), + path( + "workspaces//projects//views//", + ViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-view", + ), + ## End Views + ## Cycles + path( + "workspaces//projects//cycles/", + CycleViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//", + CycleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues/", + CycleIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-cycle", + ), + ## End Cycles + # Issue + path( + "workspaces//projects//issues/", + IssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue", + ), + path( + "workspaces//projects//issues//", + IssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue", + ), + path( + "workspaces//issues/", + WorkSpaceIssuesEndpoint.as_view(), + name="workspace-issue", + ), + path( + "workspaces//projects//issue-labels/", + LabelViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-labels", + ), + path( + "workspaces//projects//issue-labels//", + LabelViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-labels", + ), + path( + "workspaces//projects//bulk-delete-issues/", + BulkDeleteIssuesEndpoint.as_view(), + ), + path( + "workspaces//my-issues/", + UserWorkSpaceIssues.as_view(), + name="workspace-issues", + ), + ## End Issues + ## Issue Activity + path( + "workspaces//projects//issues//history/", + IssueActivityEndpoint.as_view(), + name="project-issue-history", + ), + ## Issue Activity + ## IssueComments + path( + "workspaces//projects//issues//comments/", + IssueCommentViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-comment", + ), + ## End IssueComments + ## Roadmap + path( + "workspaces//projects//issues//roadmaps/", + TimeLineIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-roadmap", + ), + path( + "workspaces//projects//issues//roadmaps//", + TimeLineIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-roadmap", + ), + ## End Roadmap + ## IssueProperty + path( + "workspaces//projects//issue-properties/", + IssuePropertyViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-roadmap", + ), + path( + "workspaces//projects//issue-properties//", + IssuePropertyViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-roadmap", + ), + ## IssueProperty Ebd + ## File Assets + path( + "file-assets/", + FileAssetEndpoint.as_view(), + name="File Assets", + ), + ## End File Assets + ## Modules + path( + "workspaces//projects//modules/", + ModuleViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//", + ModuleViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-modules", + ), + path( + "workspaces//projects//modules//module-issues/", + ModuleIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-module-issues", + ), + path( + "workspaces//projects//modules//module-issues//", + ModuleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-module-issues", + ), + ## End Modules + # path( + # "issues//all/", + # IssueViewSet.as_view({"get": "list_issue_history_comments"}), + # name="Issue history and comments", + # ), +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py new file mode 100644 index 000000000..b641d6e2c --- /dev/null +++ b/apiserver/plane/api/views/__init__.py @@ -0,0 +1,71 @@ +from .project import ( + ProjectViewSet, + ProjectMemberViewSet, + UserProjectInvitationsViewset, + InviteProjectEndpoint, + AddTeamToProjectEndpoint, + ProjectMemberInvitationsViewset, + ProjectMemberInviteDetailViewSet, + ProjectIdentifierEndpoint, + AddMemberToProjectEndpoint, + ProjectJoinEndpoint, + ProjectUserViewsEndpoint, + ProjectMemberUserEndpoint, +) +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, + UserLastProjectWithWorkspaceEndpoint, +) +from .state import StateViewSet +from .shortcut import ShortCutViewSet +from .view import ViewViewSet +from .cycle import CycleViewSet, CycleIssueViewSet +from .asset import FileAssetEndpoint +from .issue import ( + IssueViewSet, + WorkSpaceIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + TimeLineIssueViewSet, + IssuePropertyViewSet, + LabelViewSet, + BulkDeleteIssuesEndpoint, + UserWorkSpaceIssues, +) + +from .auth_extended import ( + VerifyEmailEndpoint, + RequestEmailVerificationEndpoint, + ForgotPasswordEndpoint, + ResetPasswordEndpoint, + ChangePasswordEndpoint, +) + + +from .authentication import ( + SignInEndpoint, + SignOutEndpoint, + MagicSignInEndpoint, + MagicSignInGenerateEndpoint, +) + +from .module import ModuleViewSet, ModuleIssueViewSet diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py new file mode 100644 index 000000000..8d462b0cb --- /dev/null +++ b/apiserver/plane/api/views/asset.py @@ -0,0 +1,30 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser + +# Module imports +from .base import BaseAPIView +from plane.db.models import FileAsset +from plane.api.serializers import FileAssetSerializer + + +class FileAssetEndpoint(BaseAPIView): + + parser_classes = (MultiPartParser, FormParser) + + """ + A viewset for viewing and editing task instances. + """ + + def get(self, request): + files = FileAsset.objects.all() + serializer = FileAssetSerializer(files, context={"request": request}, many=True) + return Response(serializer.data) + + def post(self, request, *args, **kwargs): + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py new file mode 100644 index 000000000..0e24e39d0 --- /dev/null +++ b/apiserver/plane/api/views/auth_extended.py @@ -0,0 +1,159 @@ +## Python imports +import jwt + +## Django imports +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.encoding import ( + smart_str, + smart_bytes, + DjangoUnicodeDecodeError, +) +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.contrib.sites.shortcuts import get_current_site +from django.conf import settings + +## Third Party Imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework import permissions +from rest_framework_simplejwt.tokens import RefreshToken + +from sentry_sdk import capture_exception + +## Module imports +from . import BaseAPIView +from plane.api.serializers.people import ( + ChangePasswordSerializer, + ResetPasswordSerializer, +) +from plane.db.models import User +from plane.bgtasks.email_verification_task import email_verification +from plane.bgtasks.forgot_password_task import forgot_password + + +class RequestEmailVerificationEndpoint(BaseAPIView): + def get(self, request): + token = RefreshToken.for_user(request.user).access_token + current_site = settings.WEB_URL + email_verification.delay( + request.user.first_name, request.user.email, token, current_site + ) + return Response( + {"message": "Email sent successfully"}, status=status.HTTP_200_OK + ) + + +class VerifyEmailEndpoint(BaseAPIView): + def get(self, request): + token = request.GET.get("token") + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256") + user = User.objects.get(id=payload["user_id"]) + + if not user.is_email_verified: + user.is_email_verified = True + user.save() + return Response( + {"email": "Successfully activated"}, status=status.HTTP_200_OK + ) + except jwt.ExpiredSignatureError as indentifier: + return Response( + {"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST + ) + except jwt.exceptions.DecodeError as indentifier: + return Response( + {"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST + ) + + +class ForgotPasswordEndpoint(BaseAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + email = request.data.get("email") + + if User.objects.filter(email=email).exists(): + user = User.objects.get(email=email) + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + current_site = settings.WEB_URL + + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + + return Response( + {"messgae": "Check your email to reset your password"}, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Please check the email"}, status=status.HTTP_400_BAD_REQUEST + ) + + +class ResetPasswordEndpoint(BaseAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request, uidb64, token): + try: + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + if not PasswordResetTokenGenerator().check_token(user, token): + return Response( + {"error": "token is not valid, please check the new one"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + serializer = ResetPasswordSerializer(data=request.data) + + if serializer.is_valid(): + # set_password also hashes the password that the user will get + user.set_password(serializer.data.get("new_password")) + user.save() + response = { + "status": "success", + "code": status.HTTP_200_OK, + "message": "Password updated successfully", + } + + return Response(response) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except DjangoUnicodeDecodeError as indentifier: + return Response( + {"error": "token is not valid, please check the new one"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +class ChangePasswordEndpoint(BaseAPIView): + def post(self, request): + try: + serializer = ChangePasswordSerializer(data=request.data) + + user = User.objects.get(pk=request.user.id) + if serializer.is_valid(): + # Check old password + if not user.object.check_password(serializer.data.get("old_password")): + return Response( + {"old_password": ["Wrong password."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + # set_password also hashes the password that the user will get + self.object.set_password(serializer.data.get("new_password")) + self.object.save() + response = { + "status": "success", + "code": status.HTTP_200_OK, + "message": "Password updated successfully", + } + + return Response(response) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py new file mode 100644 index 000000000..ca8e2df60 --- /dev/null +++ b/apiserver/plane/api/views/authentication.py @@ -0,0 +1,365 @@ +# 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 SignUpEndpoint(BaseAPIView): + + permission_classes = (AllowAny,) + + def post(self, request): + try: + + email = request.data.get("email", False) + password = request.data.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + return Response( + {"error": "Both email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError as e: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.filter(email=email).first() + + if user is not None: + return Response( + {"error": "Email ID is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create(email=email) + user.set_password(password) + + # settings last actives for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class SignInEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + 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: + 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: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class MagicSignInEndpoint(BaseAPIView): + + permission_classes = [ + AllowAny, + ] + + def post(self, request): + try: + + user_token = request.data.get("token", "").strip().lower() + key = request.data.get("key", False) + + if not key or user_token == "": + return Response( + {"error": "User token and key are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ri = redis_instance() + + if ri.exists(key): + + data = json.loads(ri.get(key)) + + token = data["token"] + email = data["email"] + + if str(token) == str(user_token): + + if User.objects.filter(email=email).exists(): + user = User.objects.get(email=email) + else: + user = User.objects.create( + email=email, username=uuid.uuid4().hex + ) + + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + return Response(data, status=status.HTTP_200_OK) + + else: + return Response( + {"error": "Your login code was incorrect. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + else: + return Response( + {"error": "The magic code/link has expired please try again"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py new file mode 100644 index 000000000..f74542aac --- /dev/null +++ b/apiserver/plane/api/views/base.py @@ -0,0 +1,142 @@ +# Django imports +from django.urls import resolve +from django.conf import settings +# Third part imports +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import APIException +from rest_framework.views import APIView +from rest_framework.filters import SearchFilter +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import NotFound + +from django_filters.rest_framework import DjangoFilterBackend + +# Module imports +from plane.db.models import Workspace, Project +from plane.utils.paginator import BasePaginator + + +class BaseViewSet(ModelViewSet, BasePaginator): + + model = None + + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def get_queryset(self): + try: + return self.model.objects.all() + except Exception as e: + print(e) + raise APIException( + "Please check the view", status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + print(f'# of Queries: {len(connection.queries)}') + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def workspace(self): + if self.workspace_slug: + try: + return Workspace.objects.get(slug=self.workspace_slug) + except Workspace.DoesNotExist: + raise NotFound(detail="Workspace does not exist") + else: + return None + + @property + def project_id(self): + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) + + @property + def project(self): + if self.project_id: + try: + return Project.objects.get(pk=self.project_id) + except Project.DoesNotExist: + raise NotFound(detail="Project does not exist") + else: + return None + + +class BaseAPIView(APIView, BasePaginator): + + permission_classes = [ + IsAuthenticated, + ] + + filter_backends = ( + DjangoFilterBackend, + SearchFilter, + ) + + filterset_fields = [] + + search_fields = [] + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + print(f'# of Queries: {len(connection.queries)}') + return response + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def workspace(self): + if self.workspace_slug: + try: + return Workspace.objects.get(slug=self.workspace_slug) + except Workspace.DoesNotExist: + raise NotFound(detail="Workspace does not exist") + else: + return None + + @property + def project_id(self): + return self.kwargs.get("project_id", None) + + @property + def project(self): + if self.project_id: + try: + return Project.objects.get(pk=self.project_id) + except Project.DoesNotExist: + raise NotFound(detail="Project does not exist") + else: + return None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py new file mode 100644 index 000000000..aca7fb641 --- /dev/null +++ b/apiserver/plane/api/views/cycle.py @@ -0,0 +1,117 @@ +# 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 +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() + ) + + def create(self, request, slug, project_id, cycle_id): + try: + + issues = request.data.get("issues", []) + + if not len(issues): + return Response( + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + ) + + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + + issues = Issue.objects.filter( + pk__in=issues, workspace__slug=slug, project_id=project_id + ) + + # Delete old records in order to maintain the database integrity + CycleIssue.objects.filter(issue_id__in=issues).delete() + + 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 + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py new file mode 100644 index 000000000..9dcbb2724 --- /dev/null +++ b/apiserver/plane/api/views/issue.py @@ -0,0 +1,411 @@ +# 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, + CycleIssue, + ModuleIssue +) + + +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"), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle", + queryset=CycleIssue.objects.select_related("cycle", "issue"), + ), + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ), + ) + ) + + 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 UserWorkSpaceIssues(BaseAPIView): + def get(self, request, slug): + try: + issues = Issue.objects.filter( + assignees__in=[request.user], workspace__slug=slug + ) + serializer = IssueSerializer(issues, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class WorkSpaceIssuesEndpoint(BaseAPIView): + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def get(self, request, slug): + try: + issues = Issue.objects.filter(workspace__slug=slug).filter( + project__project_projectmember__member=self.request.user + ) + serializer = IssueSerializer(issues, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class IssueActivityEndpoint(BaseAPIView): + def get(self, request, slug, project_id, issue_id): + try: + issue_activities = IssueActivity.objects.filter(issue_id=issue_id).filter( + project__project_projectmember__member=self.request.user + ) + serializer = IssueActivitySerializer(issue_activities, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class IssueCommentViewSet(BaseViewSet): + + serializer_class = IssueCommentSerializer + model = IssueComment + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + actor=self.request.user if self.request.user is not None else None, + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + +class TimeLineIssueViewSet(BaseViewSet): + serializer_class = TimeLineIssueSerializer + model = TimelineIssue + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + +class IssuePropertyViewSet(BaseViewSet): + serializer_class = IssuePropertySerializer + model = IssueProperty + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), user=self.request.user + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(user=self.request.user) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + ) + + def list(self, request, slug, project_id): + queryset = self.get_queryset() + serializer = IssuePropertySerializer(queryset, many=True) + return Response( + serializer.data[0] if len(serializer.data) > 0 else [], + status=status.HTTP_200_OK, + ) + + def create(self, request, slug, project_id): + try: + + issue_property, created = IssueProperty.objects.get_or_create( + user=request.user, + project_id=project_id, + ) + + if not created: + issue_property.properties = request.data.get("properties", {}) + issue_property.save() + + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + issue_property.properties = request.data.get("properties", {}) + issue_property.save() + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class LabelViewSet(BaseViewSet): + + serializer_class = LabelSerializer + model = Label + permission_classes = [ + ProjectMemberPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("parent") + .distinct() + ) + + +class BulkDeleteIssuesEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def delete(self, request, slug, project_id): + try: + + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + + total_issues = len(issues) + + issues.delete() + + return Response( + {"message": f"{total_issues} issues were deleted"}, + status=status.HTTP_200_OK, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py new file mode 100644 index 000000000..a2475b390 --- /dev/null +++ b/apiserver/plane/api/views/module.py @@ -0,0 +1,158 @@ +# Django Imports +from django.db import IntegrityError +from django.db.models import Prefetch + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + +# Module imports +from . import BaseViewSet +from plane.api.serializers import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, +) +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Module, ModuleIssue, Project, Issue + + +class ModuleViewSet(BaseViewSet): + + model = Module + permission_classes = [ + ProjectEntityPermission, + ] + + def get_serializer_class(self): + return ( + ModuleWriteSerializer + if self.action in ["create", "update", "partial_update"] + else ModuleSerializer + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ) + ) + ) + + def create(self, request, slug, project_id): + try: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + serializer = ModuleWriteSerializer( + data=request.data, context={"project": project} + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The module name is already taken"}, + status=status.HTTP_410_GONE, + ) + 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 ModuleIssueViewSet(BaseViewSet): + + serializer_class = ModuleIssueSerializer + model = ModuleIssue + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("module") + .select_related("issue") + .distinct() + ) + + def create(self, request, slug, project_id, module_id): + try: + issues = request.data.get("issues", []) + if not len(issues): + return Response( + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + ) + module = Module.objects.get( + workspace__slug=slug, project_id=project_id, pk=module_id + ) + + issues = Issue.objects.filter( + pk__in=issues, workspace__slug=slug, project_id=project_id + ) + + ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + module=module, + issue=issue, + project_id=project_id, + workspace=module.workspace, + created_by=request.user, + updated_by=request.user, + ) + for issue in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + return Response({"message": "Success"}, status=status.HTTP_200_OK) + except Module.DoesNotExist: + return Response( + {"error": "Module 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, + ) diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py new file mode 100644 index 000000000..ac8b55c7e --- /dev/null +++ b/apiserver/plane/api/views/oauth.py @@ -0,0 +1,269 @@ +# Python imports +import uuid +import requests +import os + +# Django imports +from django.utils import timezone + +# Third Party modules +from rest_framework.response import Response +from rest_framework import exceptions +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework import status + +# sso authentication +from google.oauth2 import id_token +from google.auth.transport import requests as google_auth_request + +# Module imports +from plane.db.models import SocialLoginConnection, User +from plane.api.serializers import UserSerializer +from .base import BaseAPIView + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +def validate_google_token(token, client_id): + try: + + id_info = id_token.verify_oauth2_token( + token, google_auth_request.Request(), client_id + ) + email = id_info.get("email") + first_name = id_info.get("given_name") + last_name = id_info.get("family_name", "") + data = { + "email": email, + "first_name": first_name, + "last_name": last_name, + } + return data + except Exception as e: + print(e) + raise exceptions.AuthenticationFailed("Error with Google connection.") + + +def get_access_token(request_token: str, client_id: str) -> str: + """Obtain the request token from github. + Given the client id, client secret and request issued out by GitHub, this method + should give back an access token + Parameters + ---------- + CLIENT_ID: str + A string representing the client id issued out by github + CLIENT_SECRET: str + A string representing the client secret issued out by github + request_token: str + A string representing the request token issued out by github + Throws + ------ + ValueError: + if CLIENT_ID or CLIENT_SECRET or request_token is empty or not a string + Returns + ------- + access_token: str + A string representing the access token issued out by github + """ + + if not request_token: + raise ValueError("The request token has to be supplied!") + + CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET") + + url = f"https://github.com/login/oauth/access_token?client_id={client_id}&client_secret={CLIENT_SECRET}&code={request_token}" + headers = {"accept": "application/json"} + + res = requests.post(url, headers=headers) + + data = res.json() + access_token = data["access_token"] + + return access_token + + +def get_user_data(access_token: str) -> dict: + """ + Obtain the user data from github. + Given the access token, this method should give back the user data + """ + if not access_token: + raise ValueError("The request token has to be supplied!") + if not isinstance(access_token, str): + raise ValueError("The request token has to be a string!") + + access_token = "token " + access_token + url = "https://api.github.com/user" + headers = {"Authorization": access_token} + + resp = requests.get(url=url, headers=headers) + + userData = resp.json() + + return userData + + +class OauthEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def post(self, request): + try: + + medium = request.data.get("medium", False) + id_token = request.data.get("credential", False) + client_id = request.data.get("clientId", False) + + if not medium or not id_token: + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if medium == "google": + data = validate_google_token(id_token, client_id) + + if medium == "github": + access_token = get_access_token(id_token, client_id) + data = get_user_data(access_token) + + email = data.get("email", None) + if email == None: + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if "@" in email: + user = User.objects.get(email=email) + email = data["email"] + channel = "email" + mobile_number = uuid.uuid4().hex + email_verified = True + else: + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ## Login Case + + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_medium = f"oauth" + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.is_email_verified = email_verified + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + + return Response(data, status=status.HTTP_200_OK) + + except User.DoesNotExist: + ## Signup Case + + username = uuid.uuid4().hex + + if "@" in email: + email = data["email"] + mobile_number = uuid.uuid4().hex + channel = "email" + email_verified = True + else: + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User( + username=username, + email=email, + mobile_number=mobile_number, + first_name=data["first_name"], + last_name=data["last_name"], + is_email_verified=email_verified, + is_password_autoset=True, + ) + + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_medium = "oauth" + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + "permissions": [], + } + + SocialLoginConnection.objects.update_or_create( + medium=medium, + extra_data={}, + user=user, + defaults={ + "token_data": {"id_token": id_token}, + "last_login_at": timezone.now(), + }, + ) + return Response(data, status=status.HTTP_201_CREATED) + except Exception as e: + print(e) + + return Response( + { + "error": "Something went wrong. Please try again later or contact the support team." + }, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py new file mode 100644 index 000000000..81118484c --- /dev/null +++ b/apiserver/plane/api/views/people.py @@ -0,0 +1,75 @@ +# 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 + + def get_object(self): + return self.request.user + + + +class UpdateUserOnBoardedEndpoint(BaseAPIView): + def patch(self, request): + try: + user = User.objects.get(pk=request.user.id) + user.is_onboarded = request.data.get("is_onboarded", False) + user.save() + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py new file mode 100644 index 000000000..7799c0e69 --- /dev/null +++ b/apiserver/plane/api/views/project.py @@ -0,0 +1,657 @@ +# 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, +) + +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, + "group": "backlog", + }, + { + "name": "Todo", + "color": "#eb5757", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#26b5ce", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#f2c94c", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#4cb782", + "sequence": 55000, + "group": "cancelled", + }, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + group=state["group"], + ) + 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( + {"identifier": "The project identifier is already taken"}, + status=status.HTTP_410_GONE, + ) + except Workspace.DoesNotExist as e: + return Response( + {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + except serializers.ValidationError as e: + return Response( + {"identifier": "The project identifier is already taken"}, + 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, + ) + + def partial_update(self, request, slug, pk=None): + try: + workspace = Workspace.objects.get(slug=slug) + + project = Project.objects.get(pk=pk) + + serializer = ProjectSerializer( + project, + data={**request.data}, + context={"workspace_id": workspace.id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The project name is already taken"}, + status=status.HTTP_410_GONE, + ) + except (Project.DoesNotExist or Workspace.DoesNotExist) as e: + return Response( + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + except serializers.ValidationError as e: + return Response( + {"identifier": "The project identifier is already taken"}, + 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, workspace__slug=slug + ).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, workspace__slug=slug).exists(): + return Response( + {"error": "Cannot delete an identifier of an existing project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).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, + ) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + try: + + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter( + member=request.user, project=project + ).first() + + if project_member is None: + return Response( + {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN + ) + + view_props = project_member.view_props + default_props = project_member.default_props + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get( + "default_props", default_props + ) + + project_member.save() + + return Response(status=status.HTTP_200_OK) + + except Project.DoesNotExist: + return Response( + {"error": "The requested resource does not exists"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class ProjectMemberUserEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + + project_member = ProjectMember.objects.get( + project_id=project_id, workspace__slug=slug, member=request.user + ) + serializer = ProjectMemberSerializer(project_member) + + return Response(serializer.data, status=status.HTTP_200_OK) + + except ProjectMember.DoesNotExist: + return Response( + {"error": "User not a member of the project"}, + 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, + ) diff --git a/apiserver/plane/api/views/shortcut.py b/apiserver/plane/api/views/shortcut.py new file mode 100644 index 000000000..49453fb14 --- /dev/null +++ b/apiserver/plane/api/views/shortcut.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import ShortCutSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import Shortcut + + +class ShortCutViewSet(BaseViewSet): + + serializer_class = ShortCutSerializer + model = Shortcut + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py new file mode 100644 index 000000000..8054b15dd --- /dev/null +++ b/apiserver/plane/api/views/state.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import StateSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import State + + +class StateViewSet(BaseViewSet): + + serializer_class = StateSerializer + model = State + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py new file mode 100644 index 000000000..4ae4ff2c1 --- /dev/null +++ b/apiserver/plane/api/views/view.py @@ -0,0 +1,29 @@ +# Module imports +from . import BaseViewSet +from plane.api.serializers import ViewSerializer +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import View + + +class ViewViewSet(BaseViewSet): + + serializer_class = ViewSerializer + model = View + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .distinct() + ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py new file mode 100644 index 000000000..2a412ec76 --- /dev/null +++ b/apiserver/plane/api/views/workspace.py @@ -0,0 +1,510 @@ +# 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, + ProjectMemberSerializer, +) +from plane.api.views.base import BaseAPIView +from . import BaseViewSet +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + WorkspaceMemberInvite, + Team, + ProjectMember, +) +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") + ) + + +class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): + def get(self, request): + try: + + user = User.objects.get(pk=request.user.id) + + last_workspace_id = user.last_workspace_id + + if last_workspace_id is None: + return Response( + { + "project_details": [], + "workspace_details": {}, + }, + status=status.HTTP_200_OK, + ) + + workspace = Workspace.objects.get(pk=last_workspace_id) + workspace_serializer = WorkSpaceSerializer(workspace) + + project_member = ProjectMember.objects.filter( + workspace_id=last_workspace_id, member=request.user + ).select_related("workspace", "project", "member") + + project_member_serializer = ProjectMemberSerializer( + project_member, many=True + ) + + return Response( + { + "workspace_details": workspace_serializer.data, + "project_details": project_member_serializer.data, + }, + status=status.HTTP_200_OK, + ) + + except User.DoesNotExist: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) \ No newline at end of file diff --git a/apiserver/plane/bgtasks/__init__.py b/apiserver/plane/bgtasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/bgtasks/apps.py b/apiserver/plane/bgtasks/apps.py new file mode 100644 index 000000000..03d29f3e0 --- /dev/null +++ b/apiserver/plane/bgtasks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BgtasksConfig(AppConfig): + name = 'plane.bgtasks' diff --git a/apiserver/plane/bgtasks/celery.py b/apiserver/plane/bgtasks/celery.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py new file mode 100644 index 000000000..cf233c531 --- /dev/null +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -0,0 +1,40 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import User + + +@job("default") +def email_verification(first_name, email, token, current_site): + + try: + realtivelink = "/request-email-verification/" + "?token=" + str(token) + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Verify your Email!" + + context = { + "first_name": first_name, + "verification_url": abs_url, + } + + html_content = render_to_string("emails/auth/email_verification.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py new file mode 100644 index 000000000..7d169e8cf --- /dev/null +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -0,0 +1,40 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import User + + +@job("default") +def forgot_password(first_name, email, uidb64, token, current_site): + + try: + realtivelink = f"/email-verify/?uidb64={uidb64}&token={token}/" + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Verify your Email!" + + context = { + "first_name": first_name, + "forgot_password_url": abs_url, + } + + html_content = render_to_string("emails/auth/forgot_password.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py new file mode 100644 index 000000000..5673140cd --- /dev/null +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -0,0 +1,35 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + + +@job("default") +def magic_link(email, key, token, current_site): + + try: + realtivelink = f"/magic-sign-in/?password={token}&key={key}" + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Login!" + + context = {"magic_url": abs_url, "code": token} + + html_content = render_to_string("emails/auth/magic_signin.html", context) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except Exception as e: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py new file mode 100644 index 000000000..9b1649f1f --- /dev/null +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -0,0 +1,54 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import Project, User, ProjectMemberInvite + + +@job("default") +def project_invitation(email, project_id, token, current_site): + + try: + + project = Project.objects.get(pk=project_id) + project_member_invite = ProjectMemberInvite.objects.get( + token=token, email=email + ) + + relativelink = f"/project-member-invitation/{project_member_invite.id}" + abs_url = "http://" + current_site + relativelink + + from_email_string = f"Team Plane " + + subject = f"Welcome {email}!" + + context = { + "email": email, + "first_name": project.created_by.first_name, + "project_name": project.name, + "invitation_url": abs_url, + } + + html_content = render_to_string("emails/invitations/project_invitation.html", context) + + text_content = strip_tags(html_content) + + project_member_invite.message = text_content + project_member_invite.save() + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e: + return + except Exception as e: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py new file mode 100644 index 000000000..b85a24a84 --- /dev/null +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -0,0 +1,57 @@ +# Django imports +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from django_rq import job +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import Workspace, User, WorkspaceMemberInvite + + +@job("default") +def workspace_invitation(email, workspace_id, token, current_site, invitor): + + try: + + workspace = Workspace.objects.get(pk=workspace_id) + workspace_member_invite = WorkspaceMemberInvite.objects.get( + token=token, email=email + ) + + realtivelink = ( + f"/workspace-member-invitation/{workspace_member_invite.id}?email={email}" + ) + abs_url = "http://" + current_site + realtivelink + + from_email_string = f"Team Plane " + + subject = f"Welcome {email}!" + + context = { + "email": email, + "first_name": invitor, + "workspace_name": workspace.name, + "invitation_url": abs_url, + } + + html_content = render_to_string( + "emails/invitations/workspace_invitation.html", context + ) + + text_content = strip_tags(html_content) + + workspace_member_invite.message = text_content + workspace_member_invite.save() + + msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + msg.attach_alternative(html_content, "text/html") + msg.send() + return + except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/db/__init__.py b/apiserver/plane/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/admin.py b/apiserver/plane/db/admin.py new file mode 100644 index 000000000..161ec9de6 --- /dev/null +++ b/apiserver/plane/db/admin.py @@ -0,0 +1,35 @@ +# from django.contrib import admin +# from plane.db.models import User +# from plane.db.models.workspace import Workspace, WorkspaceMember, WorkspaceMemberInvite +# from plane.db.models.project import Project, ProjectMember, ProjectMemberInvite +# from plane.db.models.cycle import Cycle, CycleIssue +# from plane.db.models.issue import ( +# Issue, +# IssueActivity, +# IssueComment, +# IssueProperty, +# TimelineIssue, +# ) +# from plane.db.models.shortcut import Shortcut +# from plane.db.models.state import State +# from plane.db.models.social_connection import SocialLoginConnection +# from plane.db.models.view import View + +# admin.site.register(User) +# admin.site.register(Workspace) +# admin.site.register(WorkspaceMember) +# admin.site.register(WorkspaceMemberInvite) +# admin.site.register(Project) +# admin.site.register(ProjectMember) +# admin.site.register(ProjectMemberInvite) +# admin.site.register(Cycle) +# admin.site.register(CycleIssue) +# admin.site.register(Issue) +# admin.site.register(IssueActivity) +# admin.site.register(IssueComment) +# admin.site.register(IssueProperty) +# admin.site.register(TimelineIssue) +# admin.site.register(Shortcut) +# admin.site.register(State) +# admin.site.register(SocialLoginConnection) +# admin.site.register(View) diff --git a/apiserver/plane/db/apps.py b/apiserver/plane/db/apps.py new file mode 100644 index 000000000..70e4445be --- /dev/null +++ b/apiserver/plane/db/apps.py @@ -0,0 +1,52 @@ +from django.apps import AppConfig +from fieldsignals import post_save_changed + + +class DbConfig(AppConfig): + name = "plane.db" + + def ready(self): + + post_save_changed.connect( + self.model_activity, + sender=self.get_model("Issue"), + ) + + def model_activity(self, sender, instance, changed_fields, **kwargs): + + verb = "created" if instance._state.adding else "changed" + + import inspect + + for frame_record in inspect.stack(): + if frame_record[3] == "get_response": + request = frame_record[0].f_locals["request"] + REQUEST_METHOD = request.method + + if REQUEST_METHOD == "POST": + + self.get_model("IssueActivity").objects.create( + issue=instance, project=instance.project, actor=instance.created_by + ) + + elif REQUEST_METHOD == "PATCH": + + try: + del changed_fields["updated_at"] + del changed_fields["updated_by"] + except KeyError as e: + pass + + for field_name, (old, new) in changed_fields.items(): + field = field_name + old_value = old + new_value = new + self.get_model("IssueActivity").objects.create( + issue=instance, + verb=verb, + field=field, + old_value=old_value, + new_value=new_value, + project=instance.project, + actor=instance.updated_by, + ) diff --git a/apiserver/plane/db/management/__init__.py b/apiserver/plane/db/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/management/commands/__init__.py b/apiserver/plane/db/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/management/commands/wait_for_db.py b/apiserver/plane/db/management/commands/wait_for_db.py new file mode 100644 index 000000000..365452a7a --- /dev/null +++ b/apiserver/plane/db/management/commands/wait_for_db.py @@ -0,0 +1,19 @@ +import time +from django.db import connections +from django.db.utils import OperationalError +from django.core.management import BaseCommand + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def handle(self, *args, **options): + self.stdout.write('Waiting for database...') + db_conn = None + while not db_conn: + try: + db_conn = connections['default'] + except OperationalError: + self.stdout.write('Database unavailable, waititng 1 second...') + time.sleep(1) + + self.stdout.write(self.style.SUCCESS('Database available!')) diff --git a/apiserver/plane/db/migrations/0001_initial.py b/apiserver/plane/db/migrations/0001_initial.py new file mode 100644 index 000000000..dd158f0a8 --- /dev/null +++ b/apiserver/plane/db/migrations/0001_initial.py @@ -0,0 +1,704 @@ +# Generated by Django 3.2.14 on 2022-10-26 19:37 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('username', models.CharField(max_length=128, unique=True)), + ('mobile_number', models.CharField(blank=True, max_length=255, null=True)), + ('email', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('first_name', models.CharField(blank=True, max_length=255)), + ('last_name', models.CharField(blank=True, max_length=255)), + ('avatar', models.CharField(blank=True, max_length=255)), + ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('last_location', models.CharField(blank=True, max_length=255)), + ('created_location', models.CharField(blank=True, max_length=255)), + ('is_superuser', models.BooleanField(default=False)), + ('is_managed', models.BooleanField(default=False)), + ('is_password_expired', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_email_verified', models.BooleanField(default=False)), + ('is_password_autoset', models.BooleanField(default=False)), + ('is_onboarded', models.BooleanField(default=False)), + ('token', models.CharField(blank=True, max_length=64)), + ('billing_address_country', models.CharField(default='INDIA', max_length=255)), + ('billing_address', models.JSONField(null=True)), + ('has_billing_address', models.BooleanField(default=False)), + ('user_timezone', models.CharField(default='Asia/Kolkata', max_length=255)), + ('last_active', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('last_login_time', models.DateTimeField(null=True)), + ('last_logout_time', models.DateTimeField(null=True)), + ('last_login_ip', models.CharField(blank=True, max_length=255)), + ('last_logout_ip', models.CharField(blank=True, max_length=255)), + ('last_login_medium', models.CharField(default='email', max_length=20)), + ('last_login_uagent', models.TextField(blank=True)), + ('token_updated_at', models.DateTimeField(null=True)), + ('last_workspace_id', models.UUIDField(null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'user', + 'ordering': ('-created_at',), + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Cycle', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), + ('description', models.TextField(blank=True, verbose_name='Cycle Description')), + ('start_date', models.DateField(verbose_name='Start Date')), + ('end_date', models.DateField(verbose_name='End Date')), + ('status', models.CharField(choices=[('started', 'Started'), ('completed', 'Completed')], max_length=255, verbose_name='Cycle Status')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_by_cycle', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Cycle', + 'verbose_name_plural': 'Cycles', + 'db_table': 'cycle', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Issue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Issue Name')), + ('description', models.JSONField(blank=True, verbose_name='Issue Description')), + ('priority', models.CharField(blank=True, choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], max_length=30, null=True, verbose_name='Issue Priority')), + ('start_date', models.DateField(blank=True, null=True)), + ('target_date', models.DateField(blank=True, null=True)), + ('sequence_id', models.IntegerField(default=1, verbose_name='Issue Sequence ID')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ], + options={ + 'verbose_name': 'Issue', + 'verbose_name_plural': 'Issues', + 'db_table': 'issue', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Project', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Project Name')), + ('description', models.TextField(blank=True, verbose_name='Project Description')), + ('description_rt', models.JSONField(blank=True, null=True, verbose_name='Project Description RT')), + ('description_html', models.JSONField(blank=True, null=True, verbose_name='Project Description HTML')), + ('network', models.PositiveSmallIntegerField(choices=[(0, 'Secret'), (2, 'Public')], default=2)), + ('identifier', models.CharField(blank=True, max_length=5, null=True, verbose_name='Project Identifier')), + ('slug', models.SlugField(blank=True, max_length=100)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('default_assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='default_assignee', to=settings.AUTH_USER_MODEL)), + ('project_lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_lead', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Project', + 'verbose_name_plural': 'Projects', + 'db_table': 'project', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Team', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Team Name')), + ('description', models.TextField(blank=True, verbose_name='Team Description')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ], + options={ + 'verbose_name': 'Team', + 'verbose_name_plural': 'Teams', + 'db_table': 'team', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Workspace', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Workspace Name')), + ('logo', models.URLField(blank=True, null=True, verbose_name='Logo')), + ('slug', models.SlugField(max_length=100, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner_workspace', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Workspace', + 'verbose_name_plural': 'Workspaces', + 'db_table': 'workspace', + 'ordering': ('-created_at',), + 'unique_together': {('name', 'owner')}, + }, + ), + migrations.CreateModel( + name='WorkspaceMemberInvite', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('email', models.CharField(max_length=255)), + ('accepted', models.BooleanField(default=False)), + ('token', models.CharField(max_length=255)), + ('message', models.TextField(null=True)), + ('responded_at', models.DateTimeField(null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_member_invite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace Member Invite', + 'verbose_name_plural': 'Workspace Member Invites', + 'db_table': 'workspace_member_invite', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='View', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='View Name')), + ('description', models.TextField(blank=True, verbose_name='View Description')), + ('query', models.JSONField(verbose_name='View Query')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_view', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_view', to='db.workspace')), + ], + options={ + 'verbose_name': 'View', + 'verbose_name_plural': 'Views', + 'db_table': 'view', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TimelineIssue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('sequence_id', models.FloatField(default=1.0)), + ('links', models.JSONField(blank=True, default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_timeline', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_timelineissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_timelineissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Timeline Issue', + 'verbose_name_plural': 'Timeline Issues', + 'db_table': 'issue_timeline', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TeamMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teammember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to=settings.AUTH_USER_MODEL)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.team')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teammember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.workspace')), + ], + options={ + 'verbose_name': 'Team Member', + 'verbose_name_plural': 'Team Members', + 'db_table': 'team_member', + 'ordering': ('-created_at',), + 'unique_together': {('team', 'member')}, + }, + ), + migrations.AddField( + model_name='team', + name='members', + field=models.ManyToManyField(blank=True, related_name='members', through='db.TeamMember', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='team', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='team', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_team', to='db.workspace'), + ), + migrations.CreateModel( + name='State', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='State Name')), + ('description', models.TextField(blank=True, verbose_name='State Description')), + ('color', models.CharField(max_length=255, verbose_name='State Color')), + ('slug', models.SlugField(blank=True, max_length=100)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_state', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_state', to='db.workspace')), + ], + options={ + 'verbose_name': 'State', + 'verbose_name_plural': 'States', + 'db_table': 'state', + 'ordering': ('-created_at',), + 'unique_together': {('name', 'project')}, + }, + ), + migrations.CreateModel( + name='SocialLoginConnection', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('medium', models.CharField(choices=[('Google', 'google'), ('Github', 'github')], default=None, max_length=20)), + ('last_login_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('last_received_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), + ('token_data', models.JSONField(null=True)), + ('extra_data', models.JSONField(null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_login_connections', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Social Login Connection', + 'verbose_name_plural': 'Social Login Connections', + 'db_table': 'social_login_connection', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Shortcut', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), + ('description', models.TextField(blank=True, verbose_name='Cycle Description')), + ('type', models.CharField(choices=[('repo', 'Repo'), ('direct', 'Direct')], max_length=255, verbose_name='Shortcut Type')), + ('url', models.URLField(blank=True, null=True, verbose_name='URL')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_shortcut', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_shortcut', to='db.workspace')), + ], + options={ + 'verbose_name': 'Shortcut', + 'verbose_name_plural': 'Shortcuts', + 'db_table': 'shortcut', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ProjectMemberInvite', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('email', models.CharField(max_length=255)), + ('accepted', models.BooleanField(default=False)), + ('token', models.CharField(max_length=255)), + ('message', models.TextField(null=True)), + ('responded_at', models.DateTimeField(null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmemberinvite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectmemberinvite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Member Invite', + 'verbose_name_plural': 'Project Member Invites', + 'db_table': 'project_member_invite', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='ProjectIdentifier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('name', models.CharField(max_length=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_identifier', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Project Identifier', + 'verbose_name_plural': 'Project Identifiers', + 'db_table': 'project_identifier', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_project', to='db.workspace'), + ), + migrations.CreateModel( + name='Label', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_label', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_label', to='db.workspace')), + ], + options={ + 'verbose_name': 'Label', + 'verbose_name_plural': 'Labels', + 'db_table': 'label', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueSequence', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('sequence', models.PositiveBigIntegerField(default=1)), + ('deleted', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_sequence', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuesequence', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuesequence', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Sequence', + 'verbose_name_plural': 'Issue Sequences', + 'db_table': 'issue_sequence', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueProperty', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('properties', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueproperty', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueproperty', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Property', + 'verbose_name_plural': 'Issue Properties', + 'db_table': 'issue_property', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueLabel', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.issue')), + ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.label')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelabel', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelabel_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuelabel', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Label', + 'verbose_name_plural': 'Issue Labels', + 'db_table': 'issue_label', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueComment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuecomment', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuecomment', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Comment', + 'verbose_name_plural': 'Issue Comments', + 'db_table': 'issue_comment', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueBlocker', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocker_issues', to='db.issue')), + ('blocked_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_issues', to='db.issue')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueblocker', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueblocker', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Blocker', + 'verbose_name_plural': 'Issue Blockers', + 'db_table': 'issue_blocker', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueAssignee', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueassignee', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueassignee', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Assignee', + 'verbose_name_plural': 'Issue Assignees', + 'db_table': 'issue_assignee', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'assignee')}, + }, + ), + migrations.CreateModel( + name='IssueActivity', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('verb', models.CharField(default='created', max_length=255, verbose_name='Action')), + ('field', models.CharField(blank=True, max_length=255, null=True, verbose_name='Field Name')), + ('old_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='Old Value')), + ('new_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='New Value')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_activity', to='db.issue')), + ('issue_comment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_comment', to='db.issuecomment')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueactivity', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueactivity', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Activity', + 'verbose_name_plural': 'Issue Activities', + 'db_table': 'issue_activity', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='issue', + name='assignees', + field=models.ManyToManyField(blank=True, related_name='assignee', through='db.IssueAssignee', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='issue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AddField( + model_name='issue', + name='labels', + field=models.ManyToManyField(blank=True, related_name='labels', through='db.IssueLabel', to='db.Label'), + ), + migrations.AddField( + model_name='issue', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue', to='db.issue'), + ), + migrations.AddField( + model_name='issue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issue', to='db.project'), + ), + migrations.AddField( + model_name='issue', + name='state', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='state_issue', to='db.state'), + ), + migrations.AddField( + model_name='issue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='issue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issue', to='db.workspace'), + ), + migrations.CreateModel( + name='FileAsset', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('attributes', models.JSONField(default=dict)), + ('asset', models.FileField(upload_to='library-assets')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'File Asset', + 'verbose_name_plural': 'File Assets', + 'db_table': 'file_asset', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='CycleIssue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.cycle')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycleissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycleissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cycleissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Cycle Issue', + 'verbose_name_plural': 'Cycle Issues', + 'db_table': 'cycle_issue', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='cycle', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycle', to='db.project'), + ), + migrations.AddField( + model_name='cycle', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='cycle', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cycle', to='db.workspace'), + ), + migrations.CreateModel( + name='WorkspaceMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_workspace', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_member', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace Member', + 'verbose_name_plural': 'Workspace Members', + 'db_table': 'workspace_member', + 'ordering': ('-created_at',), + 'unique_together': {('workspace', 'member')}, + }, + ), + migrations.AlterUniqueTogether( + name='team', + unique_together={('name', 'workspace')}, + ), + migrations.CreateModel( + name='ProjectMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('comment', models.TextField(blank=True, null=True)), + ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='member_project', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmember', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectmember', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Member', + 'verbose_name_plural': 'Project Members', + 'db_table': 'project_member', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'member')}, + }, + ), + migrations.AlterUniqueTogether( + name='project', + unique_together={('name', 'workspace')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py new file mode 100644 index 000000000..9c25c4518 --- /dev/null +++ b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.14 on 2022-11-04 17:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='state', + options={'ordering': ('sequence',), 'verbose_name': 'State', 'verbose_name_plural': 'States'}, + ), + migrations.RenameField( + model_name='project', + old_name='description_rt', + new_name='description_text', + ), + migrations.AddField( + model_name='issueactivity', + name='actor', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activities', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='issuecomment', + name='actor', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='state', + name='sequence', + field=models.PositiveIntegerField(default=65535), + ), + migrations.AddField( + model_name='workspace', + name='company_size', + field=models.PositiveIntegerField(default=10), + ), + migrations.AddField( + model_name='workspacemember', + name='company_role', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cycleissue', + name='issue', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue'), + ), + ] diff --git a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py new file mode 100644 index 000000000..3adac35a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-11-09 17:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0002_auto_20221104_2239'), + ] + + operations = [ + migrations.AlterField( + model_name='issueproperty', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='issueproperty', + unique_together={('user', 'project')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0004_alter_state_sequence.py b/apiserver/plane/db/migrations/0004_alter_state_sequence.py new file mode 100644 index 000000000..0d4616aea --- /dev/null +++ b/apiserver/plane/db/migrations/0004_alter_state_sequence.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-10 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0003_auto_20221109_2320'), + ] + + operations = [ + migrations.AlterField( + model_name='state', + name='sequence', + field=models.FloatField(default=65535), + ), + ] diff --git a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py new file mode 100644 index 000000000..14c280e26 --- /dev/null +++ b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2022-11-14 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0004_alter_state_sequence'), + ] + + operations = [ + migrations.AlterField( + model_name='cycle', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='End Date'), + ), + migrations.AlterField( + model_name='cycle', + name='start_date', + field=models.DateField(blank=True, null=True, verbose_name='Start Date'), + ), + ] diff --git a/apiserver/plane/db/migrations/0006_alter_cycle_status.py b/apiserver/plane/db/migrations/0006_alter_cycle_status.py new file mode 100644 index 000000000..f49e263fb --- /dev/null +++ b/apiserver/plane/db/migrations/0006_alter_cycle_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-16 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0005_auto_20221114_2127'), + ] + + operations = [ + migrations.AlterField( + model_name='cycle', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('started', 'Started'), ('completed', 'Completed')], default='draft', max_length=255, verbose_name='Cycle Status'), + ), + ] diff --git a/apiserver/plane/db/migrations/0007_label_parent.py b/apiserver/plane/db/migrations/0007_label_parent.py new file mode 100644 index 000000000..03e660473 --- /dev/null +++ b/apiserver/plane/db/migrations/0007_label_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-11-28 20:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0006_alter_cycle_status'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_label', to='db.label'), + ), + ] diff --git a/apiserver/plane/db/migrations/0008_label_colour.py b/apiserver/plane/db/migrations/0008_label_colour.py new file mode 100644 index 000000000..9e630969d --- /dev/null +++ b/apiserver/plane/db/migrations/0008_label_colour.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-11-29 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0007_label_parent'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='colour', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/apiserver/plane/db/migrations/0009_auto_20221213_2328.py b/apiserver/plane/db/migrations/0009_auto_20221213_2328.py new file mode 100644 index 000000000..077ab7e82 --- /dev/null +++ b/apiserver/plane/db/migrations/0009_auto_20221213_2328.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2022-12-13 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0008_label_colour'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='view_props', + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name='state', + name='group', + field=models.CharField(choices=[('backlog', 'Backlog'), ('unstarted', 'Unstarted'), ('started', 'Started'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='backlog', max_length=20), + ), + ] diff --git a/apiserver/plane/db/migrations/0010_auto_20221213_2348.py b/apiserver/plane/db/migrations/0010_auto_20221213_2348.py new file mode 100644 index 000000000..bd33de299 --- /dev/null +++ b/apiserver/plane/db/migrations/0010_auto_20221213_2348.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.14 on 2022-12-13 18:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0009_auto_20221213_2328'), + ] + + operations = [ + migrations.AddField( + model_name='projectidentifier', + name='workspace', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_identifiers', to='db.workspace'), + ), + migrations.AlterField( + model_name='project', + name='identifier', + field=models.CharField(max_length=5, verbose_name='Project Identifier'), + ), + migrations.AlterUniqueTogether( + name='project', + unique_together={('name', 'workspace'), ('identifier', 'workspace')}, + ), + migrations.AlterUniqueTogether( + name='projectidentifier', + unique_together={('name', 'workspace')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0011_auto_20221216_0259.py b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py new file mode 100644 index 000000000..3ea3b9d0b --- /dev/null +++ b/apiserver/plane/db/migrations/0011_auto_20221216_0259.py @@ -0,0 +1,110 @@ +# Generated by Django 3.2.14 on 2022-12-15 21:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0010_auto_20221213_2348'), + ] + + operations = [ + migrations.CreateModel( + name='Module', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='Module Name')), + ('description', models.TextField(blank=True, verbose_name='Module Description')), + ('description_text', models.JSONField(blank=True, null=True, verbose_name='Module Description RT')), + ('description_html', models.JSONField(blank=True, null=True, verbose_name='Module Description HTML')), + ('start_date', models.DateField(null=True)), + ('target_date', models.DateField(null=True)), + ('status', models.CharField(choices=[('backlog', 'Backlog'), ('planned', 'Planned'), ('in-progress', 'In Progress'), ('paused', 'Paused'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='planned', max_length=20)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('lead', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_leads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Module', + 'verbose_name_plural': 'Modules', + 'db_table': 'module', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='project', + name='icon', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name='ModuleMember', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulemember', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulemember', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Member', + 'verbose_name_plural': 'Module Members', + 'db_table': 'module_member', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'member')}, + }, + ), + migrations.AddField( + model_name='module', + name='members', + field=models.ManyToManyField(blank=True, related_name='module_members', through='db.ModuleMember', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='module', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_module', to='db.project'), + ), + migrations.AddField( + model_name='module', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='module', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_module', to='db.workspace'), + ), + migrations.CreateModel( + name='ModuleIssue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.issue')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_issues', to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_moduleissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_moduleissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Issue', + 'verbose_name_plural': 'Module Issues', + 'db_table': 'module_issues', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'issue')}, + }, + ), + migrations.AlterUniqueTogether( + name='module', + unique_together={('name', 'project')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0012_user_my_issues_prop.py b/apiserver/plane/db/migrations/0012_user_my_issues_prop.py new file mode 100644 index 000000000..96c8a33c1 --- /dev/null +++ b/apiserver/plane/db/migrations/0012_user_my_issues_prop.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-12-20 09:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0011_auto_20221216_0259'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='my_issues_prop', + field=models.JSONField(null=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0013_projectmember_default_props.py b/apiserver/plane/db/migrations/0013_projectmember_default_props.py new file mode 100644 index 000000000..2369e1863 --- /dev/null +++ b/apiserver/plane/db/migrations/0013_projectmember_default_props.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-12-20 11:29 + +from django.db import migrations, models +import plane.db.models.project + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0012_user_my_issues_prop'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='default_props', + field=models.JSONField(default=plane.db.models.project.get_default_props), + ), + ] diff --git a/apiserver/plane/db/migrations/0014_auto_20221222_1436.py b/apiserver/plane/db/migrations/0014_auto_20221222_1436.py new file mode 100644 index 000000000..3820eb03e --- /dev/null +++ b/apiserver/plane/db/migrations/0014_auto_20221222_1436.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-12-22 09:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0013_projectmember_default_props'), + ] + + operations = [ + migrations.AlterField( + model_name='moduleissue', + name='issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + ), + migrations.AlterField( + model_name='moduleissue', + name='module', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.module'), + ), + ] diff --git a/apiserver/plane/db/migrations/__init__.py b/apiserver/plane/db/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py new file mode 100644 index 000000000..b48e5c965 --- /dev/null +++ b/apiserver/plane/db/mixins.py @@ -0,0 +1,46 @@ +from django.db import models + + +class TimeAuditModel(models.Model): + + """To path when the record was created and last modified""" + + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Created At", + ) + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + + class Meta: + abstract = True + + +class UserAuditModel(models.Model): + + """To path when the record was created and last modified""" + + created_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_created_by", + verbose_name="Created By", + null=True, + ) + updated_by = models.ForeignKey( + "db.User", + on_delete=models.SET_NULL, + related_name="%(class)s_updated_by", + verbose_name="Last Modified By", + null=True, + ) + + class Meta: + abstract = True + + +class AuditModel(TimeAuditModel, UserAuditModel): + + """To path when the record was created and last modified""" + + class Meta: + abstract = True diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py new file mode 100644 index 000000000..38091aa86 --- /dev/null +++ b/apiserver/plane/db/models/__init__.py @@ -0,0 +1,40 @@ +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 + +from .module import Module, ModuleMember, ModuleIssue diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py new file mode 100644 index 000000000..2df1dee21 --- /dev/null +++ b/apiserver/plane/db/models/asset.py @@ -0,0 +1,24 @@ +# Django import +from django.db import models + +# Module import +from . import BaseModel + + +class FileAsset(BaseModel): + """ + A file asset. + """ + + attributes = models.JSONField(default=dict) + asset = models.FileField(upload_to="library-assets") + + class Meta: + verbose_name = "File Asset" + verbose_name_plural = "File Assets" + db_table = "file_asset" + ordering = ("-created_at",) + + def __str__(self): + return self.asset + diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py new file mode 100644 index 000000000..d0531e881 --- /dev/null +++ b/apiserver/plane/db/models/base.py @@ -0,0 +1,39 @@ +import uuid + +# Django imports +from django.db import models + +# Third party imports +from crum import get_current_user + +# Module imports +from ..mixins import AuditModel + + +class BaseModel(AuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + user = get_current_user() + + if user is None or user.is_anonymous: + self.created_by = None + self.updated_by = None + super(BaseModel, self).save(*args, **kwargs) + else: + # Check if the model is being created or updated + if self._state.adding: + # If created only set created_by value: set updated_by to None + self.created_by = user + self.updated_by = None + # If updated only set updated_by value don't touch created_by + self.updated_by = user + super(BaseModel, self).save(*args, **kwargs) + + def __str__(self): + return str(self.id) diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py new file mode 100644 index 000000000..8d7858445 --- /dev/null +++ b/apiserver/plane/db/models/cycle.py @@ -0,0 +1,61 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Cycle(ProjectBaseModel): + STATUS_CHOICES = ( + ("draft", "Draft"), + ("started", "Started"), + ("completed", "Completed"), + ) + name = models.CharField(max_length=255, verbose_name="Cycle Name") + description = models.TextField(verbose_name="Cycle Description", blank=True) + start_date = models.DateField(verbose_name="Start Date", blank=True, null=True) + end_date = models.DateField(verbose_name="End Date", blank=True, null=True) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owned_by_cycle", + ) + status = models.CharField( + max_length=255, + verbose_name="Cycle Status", + choices=STATUS_CHOICES, + default="draft", + ) + + class Meta: + verbose_name = "Cycle" + verbose_name_plural = "Cycles" + db_table = "cycle" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the cycle""" + return f"{self.name} <{self.project.name}>" + + +class CycleIssue(ProjectBaseModel): + """ + Cycle Issues + """ + + issue = models.OneToOneField( + "db.Issue", on_delete=models.CASCADE, related_name="issue_cycle" + ) + cycle = models.ForeignKey( + Cycle, on_delete=models.CASCADE, related_name="issue_cycle" + ) + + class Meta: + verbose_name = "Cycle Issue" + verbose_name_plural = "Cycle Issues" + db_table = "cycle_issue" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py new file mode 100644 index 000000000..908fc00e6 --- /dev/null +++ b/apiserver/plane/db/models/issue.py @@ -0,0 +1,296 @@ +# Django imports +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +# Module imports +from . import ProjectBaseModel + +# TODO: Handle identifiers for Bulk Inserts - nk +class Issue(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_issue", + ) + name = models.CharField(max_length=255, verbose_name="Issue Name") + description = models.JSONField(verbose_name="Issue Description", blank=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + null=True, + blank=True, + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="assignee", + through="IssueAssignee", + through_fields=("issue", "assignee"), + ) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + labels = models.ManyToManyField( + "db.Label", blank=True, related_name="labels", through="IssueLabel" + ) + + class Meta: + verbose_name = "Issue" + verbose_name_plural = "Issues" + db_table = "issue" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # This means that the model isn't saved to the database yet + if self._state.adding: + # Get the maximum display_id value from the database + + last_id = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] + # aggregate can return None! Check it first. + # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it + if last_id is not None: + self.sequence_id = last_id + 1 + if self.state is None: + try: + from plane.db.models import State + + self.state, created = State.objects.get_or_create( + project=self.project, name="Backlog" + ) + except ImportError: + pass + super(Issue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the issue""" + return f"{self.name} <{self.project.name}>" + + +class IssueBlocker(ProjectBaseModel): + block = models.ForeignKey( + Issue, related_name="blocker_issues", on_delete=models.CASCADE + ) + blocked_by = models.ForeignKey( + Issue, related_name="blocked_issues", on_delete=models.CASCADE + ) + + class Meta: + verbose_name = "Issue Blocker" + verbose_name_plural = "Issue Blockers" + db_table = "issue_blocker" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.block.name} {self.blocked_by.name}" + + +class IssueAssignee(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_assignee" + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_assignee", + ) + + class Meta: + unique_together = ["issue", "assignee"] + verbose_name = "Issue Assignee" + verbose_name_plural = "Issue Assignees" + db_table = "issue_assignee" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.assignee.email}" + + +class IssueActivity(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_activity" + ) + verb = models.CharField(max_length=255, verbose_name="Action", default="created") + field = models.CharField( + max_length=255, verbose_name="Field Name", blank=True, null=True + ) + old_value = models.CharField( + max_length=255, verbose_name="Old Value", blank=True, null=True + ) + new_value = models.CharField( + max_length=255, verbose_name="New Value", blank=True, null=True + ) + + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue_comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.SET_NULL, + related_name="issue_comment", + null=True, + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activities", + ) + + class Meta: + verbose_name = "Issue Activity" + verbose_name_plural = "Issue Activities" + db_table = "issue_activity" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class TimelineIssue(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_timeline" + ) + sequence_id = models.FloatField(default=1.0) + links = models.JSONField(default=dict, blank=True) + + class Meta: + verbose_name = "Timeline Issue" + verbose_name_plural = "Timeline Issues" + db_table = "issue_timeline" + ordering = ("-created_at",) + + def __str__(self): + """Return project of the project member""" + return str(self.issue) + + +class IssueComment(ProjectBaseModel): + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE) + # System can also create comment + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comments", + null=True, + ) + + class Meta: + verbose_name = "Issue Comment" + verbose_name_plural = "Issue Comments" + db_table = "issue_comment" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class IssueProperty(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_property_user", + ) + properties = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue Property" + verbose_name_plural = "Issue Properties" + db_table = "issue_property" + ordering = ("-created_at",) + unique_together = ["user", "project"] + + def __str__(self): + """Return properties status of the issue""" + return str(self.user) + + +class Label(ProjectBaseModel): + + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_label", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + colour = models.CharField(max_length=255, blank=True) + + class Meta: + verbose_name = "Label" + verbose_name_plural = "Labels" + db_table = "label" + ordering = ("-created_at",) + + def __str__(self): + return str(self.name) + + +class IssueLabel(ProjectBaseModel): + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="label_issue" + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="label_issue" + ) + + class Meta: + verbose_name = "Issue Label" + verbose_name_plural = "Issue Labels" + db_table = "issue_label" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.label.name}" + + +class IssueSequence(ProjectBaseModel): + + issue = models.ForeignKey( + Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True + ) + sequence = models.PositiveBigIntegerField(default=1) + deleted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Issue Sequence" + verbose_name_plural = "Issue Sequences" + db_table = "issue_sequence" + ordering = ("-created_at",) + + +# TODO: Find a better method to save the model +@receiver(post_save, sender=Issue) +def create_issue_sequence(sender, instance, created, **kwargs): + + if created: + IssueSequence.objects.create( + issue=instance, sequence=instance.sequence_id, project=instance.project + ) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py new file mode 100644 index 000000000..8058942d5 --- /dev/null +++ b/apiserver/plane/db/models/module.py @@ -0,0 +1,88 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Module(ProjectBaseModel): + + name = models.CharField(max_length=255, verbose_name="Module Name") + description = models.TextField(verbose_name="Module Description", blank=True) + description_text = models.JSONField( + verbose_name="Module Description RT", blank=True, null=True + ) + description_html = models.JSONField( + verbose_name="Module Description HTML", blank=True, null=True + ) + start_date = models.DateField(null=True) + target_date = models.DateField(null=True) + status = models.CharField( + choices=( + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="planned", + max_length=20, + ) + lead = models.ForeignKey( + "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True + ) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="module_members", + through="ModuleMember", + through_fields=("module", "member"), + ) + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Module" + verbose_name_plural = "Modules" + db_table = "module" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} {self.start_date} {self.target_date}" + + +class ModuleMember(ProjectBaseModel): + + module = models.ForeignKey("db.Module", on_delete=models.CASCADE) + member = models.ForeignKey("db.User", on_delete=models.CASCADE) + + class Meta: + unique_together = ["module", "member"] + verbose_name = "Module Member" + verbose_name_plural = "Module Members" + db_table = "module_member" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.member}" + + +class ModuleIssue(ProjectBaseModel): + + module = models.ForeignKey( + "db.Module", on_delete=models.CASCADE, related_name="issue_module" + ) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="issue_module" + ) + + class Meta: + unique_together = ["module", "issue"] + verbose_name = "Module Issue" + verbose_name_plural = "Module Issues" + db_table = "module_issues" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.issue.name}" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py new file mode 100644 index 000000000..a84d36854 --- /dev/null +++ b/apiserver/plane/db/models/project.py @@ -0,0 +1,160 @@ +# 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"), +) + + +def get_default_props(): + return { + "issueView": "list", + "groupByProperty": None, + "orderBy": None, + "filterIssue": None, + } + + +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", + ) + 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, + ) + icon = models.CharField(max_length=255, null=True, blank=True) + + def __str__(self): + """Return name of the project""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = [["identifier", "workspace"], ["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) + view_props = models.JSONField(null=True) + default_props = models.JSONField(default=get_default_props) + + 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}>" + +# TODO: Remove workspace relation later +class ProjectIdentifier(AuditModel): + + workspace = models.ForeignKey( + "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True + ) + project = models.OneToOneField( + Project, on_delete=models.CASCADE, related_name="project_identifier" + ) + name = models.CharField(max_length=10) + + class Meta: + unique_together = ["name", "workspace"] + verbose_name = "Project Identifier" + verbose_name_plural = "Project Identifiers" + db_table = "project_identifier" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/shortcut.py b/apiserver/plane/db/models/shortcut.py new file mode 100644 index 000000000..833fb4a5c --- /dev/null +++ b/apiserver/plane/db/models/shortcut.py @@ -0,0 +1,26 @@ +# Django imports +from django.db import models + + +# Module imports +from . import ProjectBaseModel + + +class Shortcut(ProjectBaseModel): + TYPE_CHOICES = (("repo", "Repo"), ("direct", "Direct")) + name = models.CharField(max_length=255, verbose_name="Cycle Name") + description = models.TextField(verbose_name="Cycle Description", blank=True) + type = models.CharField( + max_length=255, verbose_name="Shortcut Type", choices=TYPE_CHOICES + ) + url = models.URLField(verbose_name="URL", blank=True, null=True) + + class Meta: + verbose_name = "Shortcut" + verbose_name_plural = "Shortcuts" + db_table = "shortcut" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the shortcut""" + return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py new file mode 100644 index 000000000..20f3385a0 --- /dev/null +++ b/apiserver/plane/db/models/social_connection.py @@ -0,0 +1,34 @@ +# Django imports +from django.db import models +from django.conf import settings +from django.utils import timezone + +# Module import +from . import BaseModel + + +class SocialLoginConnection(BaseModel): + medium = models.CharField( + max_length=20, + choices=(("Google", "google"), ("Github", "github")), + default=None, + ) + last_login_at = models.DateTimeField(default=timezone.now, null=True) + last_received_at = models.DateTimeField(default=timezone.now, null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="user_login_connections", + ) + token_data = models.JSONField(null=True) + extra_data = models.JSONField(null=True) + + class Meta: + verbose_name = "Social Login Connection" + verbose_name_plural = "Social Login Connections" + db_table = "social_login_connection" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the user and medium""" + return f"{self.medium} <{self.user.email}>" diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py new file mode 100644 index 000000000..7a62badd8 --- /dev/null +++ b/apiserver/plane/db/models/state.py @@ -0,0 +1,40 @@ +# 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) + group = models.CharField( + choices=( + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + default="backlog", + max_length=20, + ) + + def __str__(self): + """Return name of the state""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project"] + verbose_name = "State" + verbose_name_plural = "States" + db_table = "state" + ordering = ("sequence",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + return super().save(*args, **kwargs) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py new file mode 100644 index 000000000..1b08c8d69 --- /dev/null +++ b/apiserver/plane/db/models/user.py @@ -0,0 +1,127 @@ +# 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) + my_issues_prop = models.JSONField(null=True) + + USERNAME_FIELD = "email" + + REQUIRED_FIELDS = ["username"] + + objects = UserManager() + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + db_table = "user" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.username} <{self.email}>" + + def save(self, *args, **kwargs): + self.email = self.email.lower().strip() + self.mobile_number = self.mobile_number + + if self.token_updated_at is not None: + self.token = uuid.uuid4().hex + uuid.uuid4().hex + self.token_updated_at = timezone.now() + + if self.is_superuser: + self.is_staff = True + + super(User, self).save(*args, **kwargs) + + +@receiver(post_save, sender=User) +def send_welcome_email(sender, instance, created, **kwargs): + try: + if created: + first_name = instance.first_name.capitalize() + to_email = instance.email + from_email_string = f"Team Plane " + + subject = f"Welcome {first_name}!" + + context = {"first_name": first_name, "email": instance.email} + + html_content = render_to_string( + "emails/auth/user_welcome_email.html", context + ) + + text_content = strip_tags(html_content) + + msg = EmailMultiAlternatives( + subject, text_content, from_email_string, [to_email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py new file mode 100644 index 000000000..db7234cc2 --- /dev/null +++ b/apiserver/plane/db/models/view.py @@ -0,0 +1,22 @@ +# Django imports +from django.db import models + + +# Module import +from . import ProjectBaseModel + + +class View(ProjectBaseModel): + name = models.CharField(max_length=255, verbose_name="View Name") + description = models.TextField(verbose_name="View Description", blank=True) + query = models.JSONField(verbose_name="View Query") + + class Meta: + verbose_name = "View" + verbose_name_plural = "Views" + db_table = "view" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the View""" + return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py new file mode 100644 index 000000000..3474a8735 --- /dev/null +++ b/apiserver/plane/db/models/workspace.py @@ -0,0 +1,134 @@ +# Django imports +from django.db import models +from django.template.defaultfilters import slugify +from django.conf import settings + +# Module imports +from . import BaseModel + + +ROLE_CHOICES = ( + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), +) + + +class Workspace(BaseModel): + name = models.CharField(max_length=255, verbose_name="Workspace Name") + logo = models.URLField(verbose_name="Logo", blank=True, null=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace", + ) + slug = models.SlugField(max_length=100, db_index=True, unique=True) + company_size = models.PositiveIntegerField(default=10) + + def __str__(self): + """Return name of the Workspace""" + return self.name + + class Meta: + unique_together = ["name", "owner"] + verbose_name = "Workspace" + verbose_name_plural = "Workspaces" + db_table = "workspace" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + return super().save(*args, **kwargs) + + +class WorkspaceMember(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" + ) + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="member_workspace", + ) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + company_role = models.TextField(null=True, blank=True) + + class Meta: + unique_together = ["workspace", "member"] + verbose_name = "Workspace Member" + verbose_name_plural = "Workspace Members" + db_table = "workspace_member" + ordering = ("-created_at",) + + def __str__(self): + """Return members of the workspace""" + return f"{self.member.email} <{self.workspace.name}>" + + +class WorkspaceMemberInvite(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" + ) + email = models.CharField(max_length=255) + accepted = models.BooleanField(default=False) + token = models.CharField(max_length=255) + message = models.TextField(null=True) + responded_at = models.DateTimeField(null=True) + role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) + + class Meta: + verbose_name = "Workspace Member Invite" + verbose_name_plural = "Workspace Member Invites" + db_table = "workspace_member_invite" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.email} {self.accepted}" + + +class Team(BaseModel): + name = models.CharField(max_length=255, verbose_name="Team Name") + description = models.TextField(verbose_name="Team Description", blank=True) + members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="members", + through="TeamMember", + through_fields=("team", "member"), + ) + workspace = models.ForeignKey( + Workspace, on_delete=models.CASCADE, related_name="workspace_team" + ) + + def __str__(self): + """Return name of the team""" + return f"{self.name} <{self.workspace.name}>" + + class Meta: + unique_together = ["name", "workspace"] + verbose_name = "Team" + verbose_name_plural = "Teams" + db_table = "team" + ordering = ("-created_at",) + + +class TeamMember(BaseModel): + + workspace = models.ForeignKey( + Workspace, on_delete=models.CASCADE, related_name="team_member" + ) + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member") + member = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member" + ) + + def __str__(self): + return self.team.name + + class Meta: + unique_together = ["team", "member"] + verbose_name = "Team Member" + verbose_name_plural = "Team Members" + db_table = "team_member" + ordering = ("-created_at",) diff --git a/apiserver/plane/middleware/__init__.py b/apiserver/plane/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/middleware/apps.py b/apiserver/plane/middleware/apps.py new file mode 100644 index 000000000..3da4958c1 --- /dev/null +++ b/apiserver/plane/middleware/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class Middleware(AppConfig): + name = 'plane.middleware' diff --git a/apiserver/plane/middleware/user_middleware.py b/apiserver/plane/middleware/user_middleware.py new file mode 100644 index 000000000..60dee9b73 --- /dev/null +++ b/apiserver/plane/middleware/user_middleware.py @@ -0,0 +1,33 @@ +import jwt +import pytz +from django.conf import settings +from django.utils import timezone +from plane.db.models import User + + +class UserMiddleware(object): + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + try: + if request.headers.get("Authorization"): + authorization_header = request.headers.get("Authorization") + access_token = authorization_header.split(" ")[1] + decoded = jwt.decode( + access_token, settings.SECRET_KEY, algorithms=["HS256"] + ) + id = decoded['user_id'] + user = User.objects.get(id=id) + user.last_active = timezone.now() + user.token_updated_at = None + user.save() + timezone.activate(pytz.timezone(user.user_timezone)) + except Exception as e: + print(e) + + response = self.get_response(request) + + return response diff --git a/apiserver/plane/settings/__init__.py b/apiserver/plane/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py new file mode 100644 index 000000000..00a1f5e3f --- /dev/null +++ b/apiserver/plane/settings/common.py @@ -0,0 +1,208 @@ +import os +import datetime +from datetime import timedelta + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +SECRET_KEY = os.environ.get('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + # Inhouse apps + "plane.analytics", + "plane.api", + "plane.bgtasks", + "plane.db", + "plane.utils", + "plane.web", + "plane.middleware", + # Third-party things + "rest_framework", + "rest_framework.authtoken", + "rest_framework_simplejwt.token_blacklist", + "corsheaders", + "taggit", + "fieldsignals", + "django_rq", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + # "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "crum.CurrentRequestUserMiddleware", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), +} + +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", # default + # "guardian.backends.ObjectPermissionBackend", +) + +ROOT_URLCONF = "plane.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + "templates", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +JWT_AUTH = { + "JWT_ENCODE_HANDLER": "rest_framework_jwt.utils.jwt_encode_handler", + "JWT_DECODE_HANDLER": "rest_framework_jwt.utils.jwt_decode_handler", + "JWT_PAYLOAD_HANDLER": "rest_framework_jwt.utils.jwt_payload_handler", + "JWT_PAYLOAD_GET_USER_ID_HANDLER": "rest_framework_jwt.utils.jwt_get_user_id_from_payload_handler", + "JWT_RESPONSE_PAYLOAD_HANDLER": "rest_framework_jwt.utils.jwt_response_payload_handler", + "JWT_SECRET_KEY": SECRET_KEY, + "JWT_GET_USER_SECRET_KEY": None, + "JWT_PUBLIC_KEY": None, + "JWT_PRIVATE_KEY": None, + "JWT_ALGORITHM": "HS256", + "JWT_VERIFY": True, + "JWT_VERIFY_EXPIRATION": True, + "JWT_LEEWAY": 0, + "JWT_EXPIRATION_DELTA": datetime.timedelta(seconds=604800), + "JWT_AUDIENCE": None, + "JWT_ISSUER": None, + "JWT_ALLOW_REFRESH": False, + "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=7), + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_AUTH_COOKIE": None, +} + +WSGI_APPLICATION = "plane.wsgi.application" + +# Django Sites + +SITE_ID = 1 + +# User Model +AUTH_USER_MODEL = "db.User" + +# Database + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static") +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) + +# Media Settings +MEDIA_ROOT = "mediafiles" +MEDIA_URL = "/media/" + + +# Internationalization + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "Asia/Kolkata" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +# Host for sending e-mail. +EMAIL_HOST = os.environ.get("EMAIL_HOST") +# Port for sending e-mail. +EMAIL_PORT = 587 +# Optional SMTP authentication information for EMAIL_HOST. +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), + "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), +} diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py new file mode 100644 index 000000000..e1434c219 --- /dev/null +++ b/apiserver/plane/settings/local.py @@ -0,0 +1,67 @@ +"""Development settings and globals.""" + +from __future__ import absolute_import + +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + + +from .common import * # noqa + +DEBUG = True + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": "", + "PASSWORD": "", + "HOST": "", + } +} + + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + +INSTALLED_APPS += ("debug_toolbar",) + +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) + +DEBUG_TOOLBAR_PATCH_SETTINGS = False + +INTERNAL_IPS = ("127.0.0.1",) + +CORS_ORIGIN_ALLOW_ALL = True + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True, + environment="local", + traces_sample_rate=0.7, +) + +REDIS_HOST = "localhost" +REDIS_PORT = 6379 +REDIS_URL = False + +RQ_QUEUES = { + "default": { + "HOST": "localhost", + "PORT": 6379, + "DB": 0, + "DEFAULT_TIMEOUT": 360, + }, +} + +WEB_URL = "http://localhost:3000" \ No newline at end of file diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py new file mode 100644 index 000000000..b7312fb83 --- /dev/null +++ b/apiserver/plane/settings/production.py @@ -0,0 +1,188 @@ +"""Production settings and globals.""" +from plane.settings.local import WEB_URL +from .common import * # noqa + +import dj_database_url +from urllib.parse import urlparse +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +# Database +DEBUG = True +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": os.environ.get('PGUSER'), + "PASSWORD": os.environ.get('PGPASSWORD'), + "HOST": os.environ.get('PGHOST'), + } +} + +# CORS WHITELIST ON PROD +CORS_ORIGIN_WHITELIST = [ + # "https://example.com", + # "https://sub.example.com", + # "http://localhost:8080", + # "http://127.0.0.1:9000" +] +# Parse database configuration from $DATABASE_URL +# DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = ["*"] + +# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. +CORS_ALLOW_ALL_ORIGINS = True + +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + traces_sample_rate=1, + send_default_pii=True, + environment="production", +) + +# The AWS region to connect to. +AWS_REGION = os.environ.get("AWS_REGION") + +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" + + +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" + +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = "" + +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" + +# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication +# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, +# and their permissions will be set to "public-read". +AWS_S3_BUCKET_AUTH = False + +# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` +# is True. It also affects the "Cache-Control" header of the files. +# Important: Changing this setting will not affect existing files. +AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + +# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting +# cannot be used with `AWS_S3_BUCKET_AUTH`. +AWS_S3_PUBLIC_URL = "" + +# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you +# understand the consequences before enabling. +# Important: Changing this setting will not affect existing files. +AWS_S3_REDUCED_REDUNDANCY = False + +# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_DISPOSITION = "" + +# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_LANGUAGE = "" + +# A mapping of custom metadata for each file. Each value can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_METADATA = {} + +# If True, then files will be stored using AES256 server-side encryption. +# If this is a string value (e.g., "aws:kms"), that encryption type will be used. +# Otherwise, server-side encryption is not be enabled. +# Important: Changing this setting will not affect existing files. +AWS_S3_ENCRYPT_KEY = False + +# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. +# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). +# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + +# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their +# compressed size is smaller than their uncompressed size. +# Important: Changing this setting will not affect existing files. +AWS_S3_GZIP = True + +# The signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None + +# If True, then files with the same name will overwrite each other. By default it's set to False to have +# extra characters appended. +AWS_S3_FILE_OVERWRITE = False + +# AWS Settings End + + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + + +DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + + +REDIS_URL = os.environ.get("REDIS_URL") + +REDIS_TLS_URL = os.environ.get("REDIS_TLS_URL") + +if REDIS_TLS_URL: + REDIS_URL = REDIS_TLS_URL + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + # "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } +} + +RQ_QUEUES = { + "default": { + "USE_REDIS_CACHE": "default", + } +} + +WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py new file mode 100644 index 000000000..390a075c8 --- /dev/null +++ b/apiserver/plane/settings/redis.py @@ -0,0 +1,13 @@ +import redis +import os +from django.conf import settings +from urllib.parse import urlparse + + +def redis_instance(): + if settings.REDIS_URL: + ri = redis.from_url(settings.REDIS_URL, db=0) + else: + ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) + + return ri diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py new file mode 100644 index 000000000..fb349a3d8 --- /dev/null +++ b/apiserver/plane/settings/staging.py @@ -0,0 +1,188 @@ +"""Production settings and globals.""" +from plane.settings.local import WEB_URL +from .common import * # noqa + +import dj_database_url +from urllib.parse import urlparse +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +# Database +DEBUG = False +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "plane", + "USER": "", + "PASSWORD": "", + "HOST": "", + } +} + +# CORS WHITELIST ON PROD +CORS_ORIGIN_WHITELIST = [ + # "https://example.com", + # "https://sub.example.com", + # "http://localhost:8080", + # "http://127.0.0.1:9000" +] +# Parse database configuration from $DATABASE_URL +DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = ["*"] + +# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. +CORS_ALLOW_ALL_ORIGINS = True + +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + integrations=[DjangoIntegration(), RedisIntegration()], + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + traces_sample_rate=1, + send_default_pii=True, + environment="staging", +) + +# The AWS region to connect to. +AWS_REGION = os.environ.get("AWS_REGION") + +# The AWS access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" + + +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" + +# The full URL to the S3 endpoint. Leave blank to use the default region URL. +AWS_S3_ENDPOINT_URL = "" + +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" + +# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication +# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, +# and their permissions will be set to "public-read". +AWS_S3_BUCKET_AUTH = False + +# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` +# is True. It also affects the "Cache-Control" header of the files. +# Important: Changing this setting will not affect existing files. +AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + +# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting +# cannot be used with `AWS_S3_BUCKET_AUTH`. +AWS_S3_PUBLIC_URL = "" + +# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you +# understand the consequences before enabling. +# Important: Changing this setting will not affect existing files. +AWS_S3_REDUCED_REDUNDANCY = False + +# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_DISPOSITION = "" + +# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_CONTENT_LANGUAGE = "" + +# A mapping of custom metadata for each file. Each value can be a string, or a function taking a +# single `name` argument. +# Important: Changing this setting will not affect existing files. +AWS_S3_METADATA = {} + +# If True, then files will be stored using AES256 server-side encryption. +# If this is a string value (e.g., "aws:kms"), that encryption type will be used. +# Otherwise, server-side encryption is not be enabled. +# Important: Changing this setting will not affect existing files. +AWS_S3_ENCRYPT_KEY = False + +# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. +# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). +# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + +# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their +# compressed size is smaller than their uncompressed size. +# Important: Changing this setting will not affect existing files. +AWS_S3_GZIP = True + +# The signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None + +# If True, then files with the same name will overwrite each other. By default it's set to False to have +# extra characters appended. +AWS_S3_FILE_OVERWRITE = False + +# AWS Settings End + + +# Enable Connection Pooling (if desired) +# DATABASES['default']['ENGINE'] = 'django_postgrespool' + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + + +DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage" +# Simplified static file serving. +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + + +REDIS_URL = os.environ.get("REDIS_URL") + +REDIS_TLS_URL = os.environ.get("REDIS_TLS_URL") + +if REDIS_TLS_URL: + REDIS_URL = REDIS_TLS_URL + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } +} + +RQ_QUEUES = { + "default": { + "USE_REDIS_CACHE": "default", + } +} + +WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py new file mode 100644 index 000000000..6c009997c --- /dev/null +++ b/apiserver/plane/settings/test.py @@ -0,0 +1,45 @@ +from __future__ import absolute_import + +from .common import * # noqa + +DEBUG = True + +INSTALLED_APPS.append("plane.tests") + +if os.environ.get('GITHUB_WORKFLOW'): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'github_actions', + 'USER': 'postgres', + 'PASSWORD': 'postgres', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'plane_test', + 'USER': 'postgres', + 'PASSWORD': 'password123', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } + } + +REDIS_HOST = "localhost" +REDIS_PORT = 6379 +REDIS_URL = False + +RQ_QUEUES = { + "default": { + "HOST": "localhost", + "PORT": 6379, + "DB": 0, + "DEFAULT_TIMEOUT": 360, + }, +} + +WEB_URL = "http://localhost:3000" diff --git a/apiserver/plane/static/css/style.css b/apiserver/plane/static/css/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/static/humans.txt b/apiserver/plane/static/humans.txt new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/static/js/script.js b/apiserver/plane/static/js/script.js new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py new file mode 100644 index 000000000..f77d5060c --- /dev/null +++ b/apiserver/plane/tests/__init__.py @@ -0,0 +1 @@ +from .api import * \ No newline at end of file diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py new file mode 100644 index 000000000..fec51303a --- /dev/null +++ b/apiserver/plane/tests/api/base.py @@ -0,0 +1,34 @@ +# Third party imports +from rest_framework.test import APITestCase, APIClient + +# Module imports +from plane.db.models import User +from plane.api.views.authentication import get_tokens_for_user + + +class BaseAPITest(APITestCase): + def setUp(self): + self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") + + +class AuthenticatedAPITest(BaseAPITest): + def setUp(self): + super().setUp() + + ## Create Dummy User + self.email = "user@plane.so" + user = User.objects.create(email=self.email) + user.set_password("user@123") + user.save() + + # Set user + self.user = user + + # Set Up User ID + self.user_id = user.id + + access_token, _ = get_tokens_for_user(user) + self.access_token = access_token + + # Set Up Authentication Token + self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token) diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py new file mode 100644 index 000000000..51a36ba2f --- /dev/null +++ b/apiserver/plane/tests/api/test_asset.py @@ -0,0 +1 @@ +# TODO: Tests for File Asset Uploads \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py new file mode 100644 index 000000000..92ad92d6e --- /dev/null +++ b/apiserver/plane/tests/api/test_auth_extended.py @@ -0,0 +1 @@ +#TODO: Tests for ChangePassword and other Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py new file mode 100644 index 000000000..4fc46e008 --- /dev/null +++ b/apiserver/plane/tests/api/test_authentication.py @@ -0,0 +1,209 @@ +# Python import +import json + +# Django imports +from django.urls import reverse + +# Third Party imports +from rest_framework import status +from .base import BaseAPITest + +# Module imports +from plane.db.models import User +from plane.settings.redis import redis_instance + + +class SignInEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("sign-in") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_validity(self): + + url = reverse("sign-in") + response = self.client.post( + url, {"email": "useremail.com", "password": "user@123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Please provide a valid email address."} + ) + + def test_password_validity(self): + url = reverse("sign-in") + response = self.client.post( + url, {"email": "user@plane.so", "password": "user123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data, + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + ) + + def test_user_exists(self): + url = reverse("sign-in") + response = self.client.post( + url, {"email": "user@email.so", "password": "user123"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.data, + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + ) + + def test_user_login(self): + url = reverse("sign-in") + + response = self.client.post( + url, + {"email": "user@plane.so", "password": "user@123"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data.get("user").get("email"), + "user@plane.so", + ) + + +class MagicLinkGenerateEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("magic-generate") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_validity(self): + + url = reverse("magic-generate") + response = self.client.post(url, {"email": "useremail.com"}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Please provide a valid email address."} + ) + + def test_magic_generate(self): + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = self.client.post(url, {"email": "user@plane.so"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_max_generate_attempt(self): + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + response = self.client.post( + url, + {"email": "user@plane.so"}, + format="json", + ) + + response = self.client.post( + url, + {"email": "user@plane.so"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Max attempts exhausted. Please try again later."} + ) + + +class MagicSignInEndpointTests(BaseAPITest): + def setUp(self): + super().setUp() + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + + def test_without_data(self): + + url = reverse("magic-sign-in") + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {"error": "User token and key are required"}) + + def test_expired_invalid_magic_link(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "The magic code/link has expired please try again"} + ) + + def test_invalid_magic_code(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + ## Create Token + url = reverse("magic-generate") + self.client.post(url, {"email": "user@plane.so"}, format="json") + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, {"error": "Your login code was incorrect. Please try again."} + ) + + def test_magic_code_sign_in(self): + + ri = redis_instance() + ri.delete("magic_user@plane.so") + ## Create Token + url = reverse("magic-generate") + self.client.post(url, {"email": "user@plane.so"}, format="json") + + # Get the token + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + url = reverse("magic-sign-in") + response = self.client.post( + url, + {"key": "magic_user@plane.so", "token": token}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data.get("user").get("email"), + "user@plane.so", + ) diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py new file mode 100644 index 000000000..04c2d6ba2 --- /dev/null +++ b/apiserver/plane/tests/api/test_cycle.py @@ -0,0 +1 @@ +# TODO: Write Test for Cycle Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py new file mode 100644 index 000000000..3e59613e0 --- /dev/null +++ b/apiserver/plane/tests/api/test_issue.py @@ -0,0 +1 @@ +# TODO: Write Test for Issue Endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py new file mode 100644 index 000000000..e70e4fccb --- /dev/null +++ b/apiserver/plane/tests/api/test_oauth.py @@ -0,0 +1 @@ +#TODO: Tests for OAuth Authentication Endpoint \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py new file mode 100644 index 000000000..c4750f9b8 --- /dev/null +++ b/apiserver/plane/tests/api/test_people.py @@ -0,0 +1 @@ +# TODO: Write Test for people Endpoint \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py new file mode 100644 index 000000000..49dae5581 --- /dev/null +++ b/apiserver/plane/tests/api/test_project.py @@ -0,0 +1 @@ +# TODO: Write Tests for project endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py new file mode 100644 index 000000000..2e939af70 --- /dev/null +++ b/apiserver/plane/tests/api/test_shortcut.py @@ -0,0 +1 @@ +# TODO: Write Test for shortcuts \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py new file mode 100644 index 000000000..ef9631bc2 --- /dev/null +++ b/apiserver/plane/tests/api/test_state.py @@ -0,0 +1 @@ +# TODO: Wrote test for state endpoints \ No newline at end of file diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py new file mode 100644 index 000000000..c8864f28a --- /dev/null +++ b/apiserver/plane/tests/api/test_view.py @@ -0,0 +1 @@ +# TODO: Write test for view endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py new file mode 100644 index 000000000..a1da2997a --- /dev/null +++ b/apiserver/plane/tests/api/test_workspace.py @@ -0,0 +1,43 @@ +# Django imports +from django.urls import reverse + +# Third party import +from rest_framework import status + +# Module imports +from .base import AuthenticatedAPITest +from plane.db.models import Workspace, WorkspaceMember + + +class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): + def setUp(self): + super().setUp() + + def test_create_workspace(self): + + url = reverse("workspace") + + # Test with empty data + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Test with valid data + response = self.client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Workspace.objects.count(), 1) + # Check if the member is created + self.assertEqual(WorkspaceMember.objects.count(), 1) + + # Check other values + workspace = Workspace.objects.get(pk=response.data["id"]) + workspace_member = WorkspaceMember.objects.get(workspace=workspace, member_id=self.user_id) + self.assertEqual(workspace.owner_id, self.user_id) + self.assertEqual(workspace_member.role, 20) + + # Create a already existing workspace + response = self.client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_410_GONE) diff --git a/apiserver/plane/tests/apps.py b/apiserver/plane/tests/apps.py new file mode 100644 index 000000000..577414e63 --- /dev/null +++ b/apiserver/plane/tests/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.tests" diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py new file mode 100644 index 000000000..3dfde38bd --- /dev/null +++ b/apiserver/plane/urls.py @@ -0,0 +1,29 @@ +"""plane URL Configuration + +""" + +# from django.contrib import admin +from django.urls import path +from django.views.generic import TemplateView + +from django.conf import settings +from django.conf.urls import include, url + +# from django.conf.urls.static import static + +urlpatterns = [ + # path("admin/", admin.site.urls), + path("", TemplateView.as_view(template_name="index.html")), + path("api/", include("plane.api.urls")), + path("", include("plane.web.urls")), +] +# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + +if settings.DEBUG: + import debug_toolbar + + urlpatterns = [ + url(r"^__debug__/", include(debug_toolbar.urls)), + ] + urlpatterns diff --git a/apiserver/plane/utils/__init__.py b/apiserver/plane/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py new file mode 100644 index 000000000..1a0d2924e --- /dev/null +++ b/apiserver/plane/utils/imports.py @@ -0,0 +1,20 @@ +import pkgutil +import six + + +def import_submodules(context, root_module, path): + """ + Import all submodules and register them in the ``context`` namespace. + >>> import_submodules(locals(), __name__, __path__) + """ + for loader, module_name, is_pkg in pkgutil.walk_packages( + path, + root_module + + '.'): + # this causes a Runtime error with model conflicts + # module = loader.find_module(module_name).load_module(module_name) + module = __import__(module_name, globals(), locals(), ['__name__']) + for k, v in six.iteritems(vars(module)): + if not k.startswith('_'): + context[k] = v + context[module_name] = module \ No newline at end of file diff --git a/apiserver/plane/utils/ip_address.py b/apiserver/plane/utils/ip_address.py new file mode 100644 index 000000000..29a2fa520 --- /dev/null +++ b/apiserver/plane/utils/ip_address.py @@ -0,0 +1,7 @@ +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip \ No newline at end of file diff --git a/apiserver/plane/utils/markdown.py b/apiserver/plane/utils/markdown.py new file mode 100644 index 000000000..15d5b4dce --- /dev/null +++ b/apiserver/plane/utils/markdown.py @@ -0,0 +1,3 @@ +import mistune + +markdown = mistune.Markdown() \ No newline at end of file diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py new file mode 100644 index 000000000..b3c50abd1 --- /dev/null +++ b/apiserver/plane/utils/paginator.py @@ -0,0 +1,227 @@ +from rest_framework.response import Response +from rest_framework.exceptions import ParseError +from collections.abc import Sequence +import math + + +class Cursor: + def __init__(self, value, offset=0, is_prev=False, has_results=None): + self.value = value + self.offset = int(offset) + self.is_prev = bool(is_prev) + self.has_results = has_results + + def __str__(self): + return f"{self.value}:{self.offset}:{int(self.is_prev)}" + + def __eq__(self, other): + return all( + getattr(self, attr) == getattr(other, attr) + for attr in ("value", "offset", "is_prev", "has_results") + ) + + def __repr__(self): + return "<{}: value={} offset={} is_prev={}>".format( + type(self).__name__, + self.value, + self.offset, + int(self.is_prev), + ) + + def __bool__(self): + return bool(self.has_results) + + @classmethod + def from_string(cls, value): + bits = value.split(":") + if len(bits) != 3: + raise ValueError + try: + value = float(bits[0]) if "." in bits[0] else int(bits[0]) + bits = value, int(bits[1]), int(bits[2]) + except (TypeError, ValueError): + raise ValueError + return cls(*bits) + + +class CursorResult(Sequence): + def __init__(self, results, next, prev, hits=None, max_hits=None): + self.results = results + self.next = next + self.prev = prev + self.hits = hits + self.max_hits = max_hits + + def __len__(self): + return len(self.results) + + def __iter__(self): + return iter(self.results) + + def __getitem__(self, key): + return self.results[key] + + def __repr__(self): + return f"<{type(self).__name__}: results={len(self.results)}>" + + +MAX_LIMIT = 100 + + +class BadPaginationError(Exception): + pass + + +class OffsetPaginator: + """ + The Offset paginator using the offset and limit + with cursor controls + http://example.com/api/users/?cursor=10.0.0&per_page=10 + cursor=limit,offset=page, + """ + + def __init__( + self, + queryset, + order_by=None, + max_limit=MAX_LIMIT, + max_offset=None, + on_results=None, + ): + self.key = ( + order_by + if order_by is None or isinstance(order_by, (list, tuple, set)) + else (order_by,) + ) + self.queryset = queryset + self.max_limit = max_limit + self.max_offset = max_offset + self.on_results = on_results + + def get_result(self, limit=100, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + queryset = self.queryset + if self.key: + queryset = queryset.order_by(*self.key) + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + results = list(queryset[offset:stop]) + if cursor.value != limit: + results = results[-(limit + 1) :] + + next_cursor = Cursor(limit, page + 1, False, len(results) > limit) + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + results = list(results[:limit]) + if self.on_results: + results = self.on_results(results) + + max_hits = math.ceil(queryset.count() / limit) + + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=None, + max_hits=max_hits, + ) + + +class BasePaginator: + """BasePaginator class can be inherited by any View to return a paginated view""" + + # cursor query parameter name + cursor_name = "cursor" + + # get the per page parameter from request + def get_per_page(self, request, default_per_page=100, max_per_page=100): + try: + per_page = int(request.GET.get("per_page", default_per_page)) + except ValueError: + raise ParseError(detail="Invalid per_page parameter.") + + max_per_page = max(max_per_page, default_per_page) + if per_page > max_per_page: + raise ParseError( + detail=f"Invalid per_page value. Cannot exceed {max_per_page}." + ) + + return per_page + + def paginate( + self, + request, + on_results=None, + paginator=None, + paginator_cls=OffsetPaginator, + default_per_page=100, + max_per_page=100, + cursor_cls=Cursor, + extra_stats=None, + controller=None, + **paginator_kwargs, + ): + """Paginate the request""" + assert (paginator and not paginator_kwargs) or ( + paginator_cls and paginator_kwargs + ) + + per_page = self.get_per_page(request, default_per_page, max_per_page) + + # Convert the cursor value to integer and float from string + input_cursor = None + if request.GET.get(self.cursor_name): + try: + input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name)) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") + + if not paginator: + paginator = paginator_cls(**paginator_kwargs) + + try: + cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) + except BadPaginationError as e: + raise ParseError(detail=str(e)) + + # Serialize result according to the on_result function + if on_results: + results = on_results(cursor_result.results) + else: + results = cursor_result.results + + # Add Manipulation functions to the response + if controller is not None: + results = controller(results) + else: + results = results + + # Return the response + response = Response( + { + "next_cursor": str(cursor_result.next), + "prev_cursor": str(cursor_result.prev), + "next_page_results": cursor_result.next.has_results, + "prev_page_results": cursor_result.prev.has_results, + "count": cursor_result.__len__(), + "total_pages": cursor_result.max_hits, + "extra_stats": extra_stats, + "results": results, + } + ) + + return response diff --git a/apiserver/plane/web/__init__.py b/apiserver/plane/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/web/apps.py b/apiserver/plane/web/apps.py new file mode 100644 index 000000000..76ca3c4e6 --- /dev/null +++ b/apiserver/plane/web/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebConfig(AppConfig): + name = 'plane.web' diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py new file mode 100644 index 000000000..568b99037 --- /dev/null +++ b/apiserver/plane/web/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from django.views.generic import TemplateView + +urlpatterns = [ + path('about/', TemplateView.as_view(template_name='about.html')) + +] diff --git a/apiserver/plane/web/views.py b/apiserver/plane/web/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/apiserver/plane/web/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apiserver/plane/wsgi.py b/apiserver/plane/wsgi.py new file mode 100644 index 000000000..ef3ea2780 --- /dev/null +++ b/apiserver/plane/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for plane project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', + 'plane.settings.production') + +application = get_wsgi_application() diff --git a/apiserver/requirements.txt b/apiserver/requirements.txt new file mode 100644 index 000000000..9887773e7 --- /dev/null +++ b/apiserver/requirements.txt @@ -0,0 +1,3 @@ +# This file is here because many Platforms as a Service look for +# requirements.txt in the root directory of a project. +-r requirements/production.txt \ No newline at end of file diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt new file mode 100644 index 000000000..e845e4561 --- /dev/null +++ b/apiserver/requirements/base.txt @@ -0,0 +1,28 @@ +# base requirements + +Django==3.2.14 +django-braces==1.15.0 +django-taggit==2.1.0 +psycopg2==2.9.3 +django-oauth-toolkit==2.0.0 +mistune==2.0.2 +djangorestframework==3.13.1 +redis==4.2.2 +django-nested-admin==3.4.0 +django-cors-headers==3.11.0 +whitenoise==6.0.0 +django-allauth==0.50.0 +faker==13.4.0 +django-filter==21.1 +jsonmodels==2.5.0 +djangorestframework-simplejwt==5.1.0 +sentry-sdk==1.5.12 +django-s3-storage==0.13.6 +django-crum==0.7.9 +django-guardian==2.4.0 +django-fieldsignals==0.7.0 +dj_rest_auth==2.2.5 +google-auth==2.9.1 +google-api-python-client==2.55.0 +django-rq==2.5.1 +django-redis==5.2.0 \ No newline at end of file diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt new file mode 100644 index 000000000..238fe63f2 --- /dev/null +++ b/apiserver/requirements/local.txt @@ -0,0 +1,3 @@ +-r base.txt + +django-debug-toolbar==3.2.4 \ No newline at end of file diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt new file mode 100644 index 000000000..231d3c0a1 --- /dev/null +++ b/apiserver/requirements/production.txt @@ -0,0 +1,12 @@ +-r base.txt + +dj-database-url==0.5.0 +gunicorn==20.1.0 +whitenoise==6.0.0 +django-storages==1.12.3 +boto==2.49.0 +django-anymail==8.5 +twilio==7.8.2 +django-debug-toolbar==3.2.4 +gevent==22.10.2 +psycogreen==1.0.2 \ No newline at end of file diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt new file mode 100644 index 000000000..d3272191e --- /dev/null +++ b/apiserver/requirements/test.txt @@ -0,0 +1,4 @@ +-r base.txt + +pytest==7.1.2 +coverage==6.5.0 \ No newline at end of file diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt new file mode 100644 index 000000000..e02524340 --- /dev/null +++ b/apiserver/runtime.txt @@ -0,0 +1 @@ +python-3.9.16 \ No newline at end of file diff --git a/apiserver/templates/about.html b/apiserver/templates/about.html new file mode 100644 index 000000000..91804cda4 --- /dev/null +++ b/apiserver/templates/about.html @@ -0,0 +1,9 @@ + +{% extends 'base.html' %} +{% load static %} + + +{% block content %} +

Hello from plane!

+

Made with Django

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

{% trans 'plane Admin' %}

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

+ Dear {{first_name}},

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

+

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

+ Dear {{first_name}},

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

+

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

+ Login,

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

+

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

+ Dear {{email}},

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

+ + Invitation Link: {{invitation_url}} +

+

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

+ Dear {{email}},

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

+ + Invitation Link: {{invitation_url}} +

+

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

Hello from plane!

+
+{% endblock content %} \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 000000000..017911920 --- /dev/null +++ b/app.json @@ -0,0 +1,77 @@ +{ + "name": "Plane", + "description": "Plane helps you track your issues, epics, and product roadmaps.", + "repository": "http://github.com/makeplane/plane", + "logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4", + "website": "https://plane.so/", + "success_url": "/", + "stack": "heroku-22", + "keywords": ["plane", "project management", "django", "next"], + "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], + "buildpacks": [ + { + "url": "https://github.com/heroku/heroku-buildpack-python.git" + }, + { + "url": "https://github.com/heroku/heroku-buildpack-nodejs#v176" + } + ], + "env": { + "EMAIL_HOST": { + "description": "Email host to send emails from", + "value": "" + }, + "EMAIL_HOST_USER": { + "description": "Email host to send emails from", + "value": "" + }, + "EMAIL_HOST_PASSWORD": { + "description": "Email host to send emails from", + "value": "" + }, + "AWS_REGION": { + "description": "AWS Region to use for S3", + "value": "false" + }, + "AWS_ACCESS_KEY_ID": { + "description": "AWS Access Key ID to use for S3", + "value": "" + }, + "AWS_SECRET_ACCESS_KEY": { + "description": "AWS Secret Access Key to use for S3", + "value": "" + }, + "SENTRY_DSN": { + "description": "", + "value": "" + }, + "AWS_S3_BUCKET_NAME": { + "description": "AWS Bucket Name to use for S3", + "value": "" + }, + "WEB_URL": { + "description": "Web URL for Plane", + "value": "" + }, + "GITHUB_CLIENT_SECRET": { + "description": "Github Client Secret", + "value": "" + }, + "NEXT_PUBLIC_GITHUB_ID": { + "description": "Next Public Github ID", + "value": "" + }, + "NEXT_PUBLIC_GOOGLE_CLIENTID": { + "description": "Next Public Google Client ID", + "value": "" + }, + "NEXT_PUBLIC_API_BASE_URL": { + "description": "Next Public API Base URL", + "value": "" + }, + "SECRET_KEY": { + "description": "Django Secret Key", + "value": "" + } + } +} diff --git a/.eslintrc.json b/apps/app/.eslintrc.json similarity index 100% rename from .eslintrc.json rename to apps/app/.eslintrc.json diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web new file mode 100644 index 000000000..266789f45 --- /dev/null +++ b/apps/app/Dockerfile.web @@ -0,0 +1,54 @@ +FROM node:18-alpine AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +# Set working directory +WORKDIR /app + +RUN apk add curl + +COPY ./apps ./apps +COPY ./package.json ./package.json +COPY ./.eslintrc.json ./.eslintrc.json +COPY ./yarn.lock ./yarn.lock + +RUN yarn global add turbo +RUN turbo prune --scope=app --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM node:18-alpine AS installer + +RUN apk add --no-cache libc6-compat +RUN apk update +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +RUN yarn turbo run build --filter=app... + +FROM node:18-alpine AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 plane +RUN adduser --system --uid 1001 captain +USER captain + +COPY --from=installer /app/apps/app/next.config.js . +COPY --from=installer /app/apps/app/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ +COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static + +EXPOSE 3000 + +CMD node apps/app/server.js \ No newline at end of file diff --git a/apps/app/components/command-palette/index.tsx b/apps/app/components/command-palette/index.tsx index 32f71a3db..6541bb38a 100644 --- a/apps/app/components/command-palette/index.tsx +++ b/apps/app/components/command-palette/index.tsx @@ -1,18 +1,22 @@ +// react import React, { useState, useCallback, useEffect } from "react"; // next import { useRouter } from "next/router"; -// swr -import { mutate } from "swr"; -// react hook form -import { SubmitHandler, useForm } from "react-hook-form"; -// headless ui -import { Combobox, Dialog, Transition } from "@headlessui/react"; -// services -import issuesServices from "lib/services/issues.service"; // hooks import useUser from "lib/hooks/useUser"; import useTheme from "lib/hooks/useTheme"; import useToast from "lib/hooks/useToast"; +// components +import ShortcutsModal from "components/command-palette/shortcuts"; +import CreateProjectModal from "components/project/create-project-modal"; +import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal"; +import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; +import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal"; +import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal"; +// headless ui +import { Combobox, Dialog, Transition } from "@headlessui/react"; +// ui +import { Button } from "ui"; // icons import { FolderIcon, @@ -20,25 +24,11 @@ import { ClipboardDocumentListIcon, MagnifyingGlassIcon, } from "@heroicons/react/24/outline"; -// components -import ShortcutsModal from "components/command-palette/shortcuts"; -import CreateProjectModal from "components/project/create-project-modal"; -import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; -import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; -// ui -import { Button } from "ui"; // types -import { IIssue, IssueResponse } from "types"; -// fetch keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; -// constants +import { IIssue } from "types"; +// common import { classNames, copyTextToClipboard } from "constants/common"; -type FormInput = { - issue_ids: string[]; - cycleId: string; -}; - const CommandPalette: React.FC = () => { const [query, setQuery] = useState(""); @@ -47,8 +37,10 @@ const CommandPalette: React.FC = () => { const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false); + const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false); + const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false); - const { activeWorkspace, activeProject, issues } = useUser(); + const { activeProject, issues } = useUser(); const router = useRouter(); @@ -62,8 +54,6 @@ const CommandPalette: React.FC = () => { : issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; - const { register, handleSubmit, reset } = useForm(); - const quickActions = [ { name: "Add new issue...", @@ -86,7 +76,6 @@ const CommandPalette: React.FC = () => { const handleCommandPaletteClose = () => { setIsPaletteOpen(false); setQuery(""); - reset(); }; const handleKeyDown = useCallback( @@ -109,6 +98,12 @@ const CommandPalette: React.FC = () => { } else if ((e.ctrlKey || e.metaKey) && e.key === "q") { e.preventDefault(); setIsCreateCycleModalOpen(true); + } else if ((e.ctrlKey || e.metaKey) && e.key === "m") { + e.preventDefault(); + setisCreateModuleModalOpen(true); + } else if ((e.ctrlKey || e.metaKey) && e.key === "d") { + e.preventDefault(); + setIsBulkDeleteIssuesModalOpen(true); } else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") { e.preventDefault(); @@ -133,47 +128,6 @@ const CommandPalette: React.FC = () => { [toggleCollapsed, setToastAlert, router] ); - const handleDelete: SubmitHandler = (data) => { - if (!data.issue_ids || data.issue_ids.length === 0) { - setToastAlert({ - title: "Error", - type: "error", - message: "Please select atleast one issue", - }); - return; - } - - if (activeWorkspace && activeProject) { - issuesServices - .bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data) - .then((res) => { - setToastAlert({ - title: "Success", - type: "success", - message: res.message, - }); - mutate( - PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id), - (prevData) => { - return { - ...(prevData as IssueResponse), - count: (prevData?.results ?? []).filter( - (p) => !data.issue_ids.some((id) => p.id === id) - ).length, - results: (prevData?.results ?? []).filter( - (p) => !data.issue_ids.some((id) => p.id === id) - ), - }; - }, - false - ); - }) - .catch((e) => { - console.log(e); - }); - } - }; - useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); @@ -184,17 +138,28 @@ const CommandPalette: React.FC = () => { {activeProject && ( - + <> + + + )} + {
  • {query === "" && (

    - Issues + Select issues

    )}
      @@ -271,12 +236,6 @@ const CommandPalette: React.FC = () => { {({ active }) => ( <>
      - { )} -
      - +
      -
      - {[ - { - title: "Navigation", - shortcuts: [ - { keys: "ctrl,/", description: "To open navigator" }, - { keys: "↑", description: "Move up" }, - { keys: "↓", description: "Move down" }, - { keys: "←", description: "Move left" }, - { keys: "→", description: "Move right" }, - { keys: "Enter", description: "Select" }, - { keys: "Esc", description: "Close" }, - ], - }, - { - title: "Common", - shortcuts: [ - { keys: "ctrl,p", description: "To create project" }, - { keys: "ctrl,i", description: "To create issue" }, - { keys: "ctrl,q", description: "To create cycle" }, - { keys: "ctrl,h", description: "To open shortcuts guide" }, - { - keys: "ctrl,alt,c", - description: "To copy issue url when on issue detail page.", - }, - ], - }, - ].map(({ title, shortcuts }) => ( -
      -

      {title}

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

      {description}

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

      {title}

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

      {description}

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

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

      - ))} + )}
      diff --git a/apps/app/components/common/board-view/single-board.tsx b/apps/app/components/common/board-view/single-board.tsx new file mode 100644 index 000000000..aa6baf9a8 --- /dev/null +++ b/apps/app/components/common/board-view/single-board.tsx @@ -0,0 +1,5 @@ +const SingleBoard = () => { + return <>; +}; + +export default SingleBoard; diff --git a/apps/app/components/common/board-view/single-issue.tsx b/apps/app/components/common/board-view/single-issue.tsx new file mode 100644 index 000000000..7864ddfd8 --- /dev/null +++ b/apps/app/components/common/board-view/single-issue.tsx @@ -0,0 +1,396 @@ +// next +import Link from "next/link"; +import Image from "next/image"; +// react-beautiful-dnd +import { DraggableStateSnapshot } from "react-beautiful-dnd"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; +// icons +import { TrashIcon } from "@heroicons/react/24/outline"; +import { CalendarDaysIcon } from "@heroicons/react/20/solid"; +import User from "public/user.png"; +// types +import { IIssue, IWorkspaceMember, Properties } from "types"; +// common +import { + addSpaceIfCamelCase, + classNames, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; +// constants +import { PRIORITIES } from "constants/"; +import useUser from "lib/hooks/useUser"; +import React from "react"; +import { getPriorityIcon } from "constants/global"; + +type Props = { + issue: IIssue; + properties: Properties; + snapshot?: DraggableStateSnapshot; + assignees: { + avatar: string | undefined; + first_name: string | undefined; + email: string | undefined; + }[]; + people: IWorkspaceMember[] | undefined; + handleDeleteIssue?: React.Dispatch>; + partialUpdateIssue: (formData: Partial, childIssueId: string) => void; +}; + +const SingleIssue: React.FC = ({ + issue, + properties, + snapshot, + assignees, + people, + handleDeleteIssue, + partialUpdateIssue, +}) => { + const { activeProject, states } = useUser(); + + return ( +
      +
      + {handleDeleteIssue && ( +
      + +
      + )} + + + {properties.key && ( +
      + {activeProject?.identifier}-{issue.sequence_id} +
      + )} +
      + {issue.name} +
      +
      + +
      + {properties.priority && ( + { + partialUpdateIssue({ priority: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + + {getPriorityIcon(issue?.priority ?? "None")} + + + + + {PRIORITIES?.map((priority) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "flex items-center gap-2 cursor-pointer capitalize select-none px-3 py-2" + ) + } + value={priority} + > + {getPriorityIcon(priority)} + {priority} + + ))} + + +
      + {/*
      +
      Priority
      +
      + {issue.priority ?? "None"} +
      +
      */} + + )} +
      + )} + {properties.state && ( + { + partialUpdateIssue({ state: data }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + + + {addSpaceIfCamelCase(issue.state_detail.name)} + + + + + {states?.map((state) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "flex items-center gap-2 cursor-pointer select-none px-3 py-2" + ) + } + value={state.id} + > + + {addSpaceIfCamelCase(state.name)} + + ))} + + +
      + {/*
      +
      State
      +
      {issue.state_detail.name}
      +
      */} + + )} +
      + )} + {properties.start_date && ( +
      + + {issue.start_date ? renderShortNumericDateFormat(issue.start_date) : "N/A"} + {/*
      +
      Started at
      +
      {renderShortNumericDateFormat(issue.start_date ?? "")}
      +
      */} +
      + )} + {properties.due_date && ( +
      + + {issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"} + {/*
      +
      Target date
      +
      {renderShortNumericDateFormat(issue.target_date ?? "")}
      +
      + {issue.target_date && + (issue.target_date < new Date().toISOString() + ? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days` + : findHowManyDaysLeft(issue.target_date) <= 3 + ? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days` + : "Due date")} +
      +
      */} +
      + )} + {properties.assignee && ( + { + const newData = issue.assignees ?? []; + if (newData.includes(data)) { + newData.splice(newData.indexOf(data), 1); + } else { + newData.push(data); + } + partialUpdateIssue({ assignees_list: newData }, issue.id); + }} + className="group relative flex-shrink-0" + > + {({ open }) => ( + <> +
      + +
      + {assignees.length > 0 ? ( + assignees.map((assignee, index: number) => ( +
      + {assignee.avatar && assignee.avatar !== "" ? ( +
      + {assignee?.first_name} +
      + ) : ( +
      + {assignee.first_name && assignee.first_name !== "" + ? assignee.first_name.charAt(0) + : assignee?.email?.charAt(0)} +
      + )} +
      + )) + ) : ( +
      + No user +
      + )} +
      +
      + + + + {people?.map((person) => ( + + classNames( + active ? "bg-indigo-50" : "bg-white", + "cursor-pointer select-none p-2" + ) + } + value={person.member.id} + > +
      + {person.member.avatar && person.member.avatar !== "" ? ( +
      + avatar +
      + ) : ( +
      + {person.member.first_name && person.member.first_name !== "" + ? person.member.first_name.charAt(0) + : person.member.email.charAt(0)} +
      + )} +

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

      +
      +
      + ))} +
      +
      +
      + {/*
      +
      Assigned to
      +
      + {issue.assignee_details?.length > 0 + ? issue.assignee_details.map((assignee) => assignee.first_name).join(", ") + : "No one"} +
      +
      */} + + )} +
      + )} +
      +
      +
      + ); +}; + +export default SingleIssue; diff --git a/apps/app/components/common/bulk-delete-issues-modal.tsx b/apps/app/components/common/bulk-delete-issues-modal.tsx new file mode 100644 index 000000000..a8123288e --- /dev/null +++ b/apps/app/components/common/bulk-delete-issues-modal.tsx @@ -0,0 +1,230 @@ +// react +import React, { useState } from "react"; +// swr +import { mutate } from "swr"; +// react hook form +import { SubmitHandler, useForm } from "react-hook-form"; +// services +import issuesServices from "lib/services/issues.service"; +// hooks +import useUser from "lib/hooks/useUser"; +import useToast from "lib/hooks/useToast"; +// headless ui +import { Combobox, Dialog, Transition } from "@headlessui/react"; +// ui +import { Button } from "ui"; +// icons +import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IssueResponse } from "types"; +// fetch keys +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +// common +import { classNames } from "constants/common"; + +type FormInput = { + issue_ids: string[]; + cycleId: string; +}; + +type Props = { + isOpen: boolean; + setIsOpen: React.Dispatch>; +}; + +const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { + const [query, setQuery] = useState(""); + + const { activeWorkspace, activeProject, issues } = useUser(); + + const { setToastAlert } = useToast(); + + const filteredIssues: IIssue[] = + query === "" + ? issues?.results ?? [] + : issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? + []; + + const { register, handleSubmit, reset } = useForm(); + + const handleClose = () => { + setIsOpen(false); + setQuery(""); + reset(); + }; + + const handleDelete: SubmitHandler = (data) => { + if (!data.issue_ids || data.issue_ids.length === 0) { + setToastAlert({ + title: "Error", + type: "error", + message: "Please select atleast one issue", + }); + return; + } + + if (activeWorkspace && activeProject) { + issuesServices + .bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data) + .then((res) => { + setToastAlert({ + title: "Success", + type: "success", + message: res.message, + }); + mutate( + PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id), + (prevData) => { + return { + ...(prevData as IssueResponse), + count: (prevData?.results ?? []).filter( + (p) => !data.issue_ids.some((id) => p.id === id) + ).length, + results: (prevData?.results ?? []).filter( + (p) => !data.issue_ids.some((id) => p.id === id) + ), + }; + }, + false + ); + }) + .catch((e) => { + console.log(e); + }); + } + }; + + return ( + <> + setQuery("")} appear> + + +
      + + +
      + + +
      + +
      +
      + + + {filteredIssues.length > 0 && ( + <> +
    • + {query === "" && ( +

      + Select issues +

      + )} +
        + {filteredIssues.map((issue) => ( + + classNames( + "flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2", + active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" + ) + } + > + {({ active }) => ( + <> +
        + + + + {activeProject?.identifier}-{issue.sequence_id} + + {issue.name} +
        + + )} +
        + ))} +
      +
    • + + )} +
      + + {query !== "" && filteredIssues.length === 0 && ( +
      +
      + )} +
      + +
      + +
      + +
      +
      +
      +
      +
      +
      +
      +
      + + ); +}; + +export default BulkDeleteIssuesModal; diff --git a/apps/app/components/project/cycles/CycleIssuesListModal.tsx b/apps/app/components/common/existing-issues-list-modal.tsx similarity index 62% rename from apps/app/components/project/cycles/CycleIssuesListModal.tsx rename to apps/app/components/common/existing-issues-list-modal.tsx index d25d069df..457bea180 100644 --- a/apps/app/components/project/cycles/CycleIssuesListModal.tsx +++ b/apps/app/components/common/existing-issues-list-modal.tsx @@ -2,42 +2,42 @@ import React, { useState } from "react"; // react-hook-form import { Controller, SubmitHandler, useForm } from "react-hook-form"; +// hooks +import useUser from "lib/hooks/useUser"; +import useToast from "lib/hooks/useToast"; // headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; // ui import { Button } from "ui"; -// services -import issuesServices from "lib/services/issues.service"; -// hooks -import useUser from "lib/hooks/useUser"; -import useToast from "lib/hooks/useToast"; // icons import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; // types import { IIssue, IssueResponse } from "types"; -// constants +// common import { classNames } from "constants/common"; +type FormInput = { + issues: string[]; +}; + type Props = { isOpen: boolean; handleClose: () => void; - issues: IssueResponse | undefined; - cycleId: string; + type: string; + issues: IIssue[]; + handleOnSubmit: (data: FormInput) => void; }; -type FormInput = { - issue_ids: string[]; -}; - -const CycleIssuesListModal: React.FC = ({ +const ExistingIssuesListModal: React.FC = ({ isOpen, handleClose: onClose, issues, - cycleId, + handleOnSubmit, + type, }) => { const [query, setQuery] = useState(""); - const { activeWorkspace, activeProject } = useUser(); + const { activeProject } = useUser(); const { setToastAlert } = useToast(); @@ -47,14 +47,19 @@ const CycleIssuesListModal: React.FC = ({ reset(); }; - const { handleSubmit, reset, control } = useForm({ + const { + handleSubmit, + reset, + control, + formState: { isSubmitting }, + } = useForm({ defaultValues: { - issue_ids: [], + issues: [], }, }); - const handleAddToCycle: SubmitHandler = (data) => { - if (!data.issue_ids || data.issue_ids.length === 0) { + const onSubmit: SubmitHandler = (data) => { + if (!data.issues || data.issues.length === 0) { setToastAlert({ title: "Error", type: "error", @@ -63,23 +68,14 @@ const CycleIssuesListModal: React.FC = ({ return; } - if (activeWorkspace && activeProject) { - issuesServices - .bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data) - .then((res) => { - console.log(res); - }) - .catch((e) => { - console.log(e); - }); - } + handleOnSubmit(data); + handleClose(); }; const filteredIssues: IIssue[] = query === "" - ? issues?.results ?? [] - : issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? - []; + ? issues ?? [] + : issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; return ( <> @@ -111,7 +107,7 @@ const CycleIssuesListModal: React.FC = ({
      (
      @@ -134,40 +130,43 @@ const CycleIssuesListModal: React.FC = ({
    • {query === "" && (

      - Select issues to add to cycle + Select issues to add to {type}

      )}
        - {filteredIssues.map((issue) => ( - - classNames( - "flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2", - active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" - ) - } - > - {({ selected }) => ( - <> - - - - {activeProject?.identifier}-{issue.sequence_id} - - {issue.name} - - )} - - ))} + {filteredIssues.map((issue) => { + if ((type === "cycle" && !issue.issue_cycle) || type === "module") + return ( + + classNames( + "flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2", + active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" + ) + } + > + {({ selected }) => ( + <> + + + + {activeProject?.identifier}-{issue.sequence_id} + + {issue.name} + + )} + + ); + })}
    • )} @@ -191,8 +190,13 @@ const CycleIssuesListModal: React.FC = ({ -
      @@ -205,4 +209,4 @@ const CycleIssuesListModal: React.FC = ({ ); }; -export default CycleIssuesListModal; +export default ExistingIssuesListModal; diff --git a/apps/app/components/forms/EmailPasswordForm.tsx b/apps/app/components/forms/EmailPasswordForm.tsx index a73c8b040..384b77598 100644 --- a/apps/app/components/forms/EmailPasswordForm.tsx +++ b/apps/app/components/forms/EmailPasswordForm.tsx @@ -88,9 +88,7 @@ const EmailPasswordForm = ({ onSuccess }: any) => { diff --git a/apps/app/components/lexical/editor.tsx b/apps/app/components/lexical/editor.tsx index 510c03e94..71fbb03cf 100644 --- a/apps/app/components/lexical/editor.tsx +++ b/apps/app/components/lexical/editor.tsx @@ -27,7 +27,7 @@ import { getValidatedValue } from "./helpers/editor"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; export interface RichTextEditorProps { - onChange: (state: SerializedEditorState) => void; + onChange: (state: string) => void; id: string; value: string; placeholder?: string; @@ -41,8 +41,7 @@ const RichTextEditor: React.FC = ({ }) => { const handleChange = (editorState: EditorState) => { editorState.read(() => { - let editorData = editorState.toJSON(); - if (onChange) onChange(editorData); + onChange(JSON.stringify(editorState.toJSON())); }); }; diff --git a/apps/app/components/lexical/helpers/editor.ts b/apps/app/components/lexical/helpers/editor.ts index 702269cd5..56f2ffe20 100644 --- a/apps/app/components/lexical/helpers/editor.ts +++ b/apps/app/components/lexical/helpers/editor.ts @@ -18,10 +18,12 @@ export const getValidatedValue = (value: string) => { const defaultValue = '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + console.log("Value: ", value); + if (value) { try { - console.log(value); - return value; + const data = JSON.parse(value); + return JSON.stringify(data); } catch (e) { return defaultValue; } diff --git a/apps/app/components/project/ConfirmProjectMemberRemove.tsx b/apps/app/components/project/confirm-project-member-remove.tsx similarity index 100% rename from apps/app/components/project/ConfirmProjectMemberRemove.tsx rename to apps/app/components/project/confirm-project-member-remove.tsx diff --git a/apps/app/components/project/create-project-modal.tsx b/apps/app/components/project/create-project-modal.tsx index f6ad48a78..e93fa6d10 100644 --- a/apps/app/components/project/create-project-modal.tsx +++ b/apps/app/components/project/create-project-modal.tsx @@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react"; // swr import useSWR, { mutate } from "swr"; // react hook form -import { useForm } from "react-hook-form"; +import { useForm, Controller } from "react-hook-form"; // headless import { Dialog, Transition } from "@headlessui/react"; // services import projectServices from "lib/services/project.service"; import workspaceService from "lib/services/workspace.service"; // common -import { createSimilarString } from "constants/common"; +import { createSimilarString, getRandomEmoji } from "constants/common"; // constants import { NETWORK_CHOICES } from "constants/"; // fetch keys @@ -18,7 +18,7 @@ import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import useUser from "lib/hooks/useUser"; import useToast from "lib/hooks/useToast"; // ui -import { Button, Input, TextArea, Select } from "ui"; +import { Button, Input, TextArea, Select, EmojiIconPicker } from "ui"; // types import { IProject } from "types"; @@ -32,6 +32,7 @@ const defaultValues: Partial = { identifier: "", description: "", network: 0, + icon: getRandomEmoji(), }; const IsGuestCondition: React.FC<{ @@ -83,6 +84,7 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => { reset, setError, clearErrors, + control, watch, setValue, } = useForm({ @@ -130,15 +132,6 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => { const projectName = watch("name") ?? ""; const projectIdentifier = watch("identifier") ?? ""; - if (workspaceMembers) { - const isMember = workspaceMembers.find((member) => member.member.id === user?.id); - const isGuest = workspaceMembers.find( - (member) => member.member.id === user?.id && member.role === 5 - ); - - if ((!isMember || isGuest) && isOpen) return ; - } - useEffect(() => { if (projectName && isChangeIdentifierRequired) { setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3)); @@ -157,12 +150,21 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => { projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101), projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101), ]); - }, [errors.identifier]); + }, [errors.identifier, projectIdentifier, projectName]); useEffect(() => { return () => setIsChangeIdentifierRequired(true); }, [isOpen]); + if (workspaceMembers) { + const isMember = workspaceMembers.find((member) => member.member.id === user?.id); + const isGuest = workspaceMembers.find( + (member) => member.member.id === user?.id && member.role === 5 + ); + + if ((!isMember || isGuest) && isOpen) return ; + } + return ( @@ -201,6 +203,22 @@ const CreateProjectModal: React.FC = ({ isOpen, setIsOpen }) => {

      +
      + + ( + + )} + /> +
      = ({ - cycle, - selectSprint, - workspaceSlug, - projectId, - openIssueModal, -}) => { - const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); - - const { activeWorkspace, activeProject, issues } = useUser(); - - const { data: cycleIssues } = useSWR(CYCLE_ISSUES(cycle.id), () => - cycleServices.getCycleIssues(workspaceSlug, projectId, cycle.id) - ); - - const removeIssueFromCycle = (cycleId: string, bridgeId: string) => { - if (activeWorkspace && activeProject && cycleIssues) { - mutate( - CYCLE_ISSUES(cycleId), - (prevData) => prevData?.filter((p) => p.id !== bridgeId), - false - ); - - issuesServices - .removeIssueFromCycle(activeWorkspace.slug, activeProject.id, cycleId, bridgeId) - .then((res) => { - console.log(res); - }) - .catch((e) => { - console.log(e); - }); - } - }; - - return ( - <> - setCycleIssuesListModal(false)} - issues={issues} - cycleId={cycle.id} - /> - - {({ open }) => ( -
      -
      - -
      - - - -

      {cycle.name}

      -

      - - {cycle.status === "started" - ? cycle.start_date - ? `${renderShortNumericDateFormat(cycle.start_date)} - ` - : "" - : cycle.status} - - - {cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""} - -

      -
      -
      - - - - - - - - - - - - - - -
      - - - - {(provided) => ( -
      - {cycleIssues ? ( - cycleIssues.length > 0 ? ( - cycleIssues.map((issue, index) => ( - - {(provided, snapshot) => ( -
      - -
      - - {issue.issue_details.state_detail?.name} - - - - - - - - - - -
      - -
      -
      - -
      - -
      -
      -
      -
      -
      -
      - )} -
      - )) - ) : ( -

      This cycle has no issue.

      - ) - ) : ( -
      - -
      - )} - {provided.placeholder} -
      - )} -
      -
      -
      - - - - Add issue - - - - -
      - - {(active) => ( - - )} - - - {(active) => ( - - )} - -
      -
      -
      -
      -
      - )} -
      - - ); -}; - -export default CycleView; diff --git a/apps/app/components/project/cycles/board-view/index.tsx b/apps/app/components/project/cycles/board-view/index.tsx new file mode 100644 index 000000000..3fa6af001 --- /dev/null +++ b/apps/app/components/project/cycles/board-view/index.tsx @@ -0,0 +1,96 @@ +// components +import SingleBoard from "components/project/cycles/board-view/single-board"; +// ui +import { Spinner } from "ui"; +// types +import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types"; +import useUser from "lib/hooks/useUser"; + +type Props = { + groupedByIssues: { + [key: string]: IIssue[]; + }; + properties: Properties; + selectedGroup: NestedKeyOf | null; + members: IProjectMember[] | undefined; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; + openIssuesListModal: () => void; + removeIssueFromCycle: (bridgeId: string) => void; + partialUpdateIssue: (formData: Partial, issueId: string) => void; + handleDeleteIssue: React.Dispatch>; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; +}; + +const CyclesBoardView: React.FC = ({ + groupedByIssues, + properties, + selectedGroup, + members, + openCreateIssueModal, + openIssuesListModal, + removeIssueFromCycle, + partialUpdateIssue, + handleDeleteIssue, + setPreloadedData, +}) => { + const { states } = useUser(); + + return ( + <> + {groupedByIssues ? ( +
      +
      +
      +
      + {Object.keys(groupedByIssues).map((singleGroup) => ( + m.member.id === singleGroup)?.member.first_name ?? + "loading..." + : null + } + groupedByIssues={groupedByIssues} + bgColor={ + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.color + : undefined + } + properties={properties} + removeIssueFromCycle={removeIssueFromCycle} + openIssuesListModal={openIssuesListModal} + openCreateIssueModal={openCreateIssueModal} + partialUpdateIssue={partialUpdateIssue} + handleDeleteIssue={handleDeleteIssue} + setPreloadedData={setPreloadedData} + stateId={ + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null + } + /> + ))} +
      +
      +
      +
      + ) : ( +
      + +
      + )} + + ); +}; + +export default CyclesBoardView; diff --git a/apps/app/components/project/cycles/board-view/single-board.tsx b/apps/app/components/project/cycles/board-view/single-board.tsx new file mode 100644 index 000000000..e80632971 --- /dev/null +++ b/apps/app/components/project/cycles/board-view/single-board.tsx @@ -0,0 +1,208 @@ +// react +import React, { useState } from "react"; +// swr +import useSWR from "swr"; +// services +import workspaceService from "lib/services/workspace.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// components +import SingleIssue from "components/common/board-view/single-issue"; +// ui +import { CustomMenu } from "ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; +// fetch-keys +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// common +import { addSpaceIfCamelCase } from "constants/common"; + +type Props = { + properties: Properties; + groupedByIssues: { + [key: string]: IIssue[]; + }; + selectedGroup: NestedKeyOf | null; + groupTitle: string; + createdBy: string | null; + bgColor?: string; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; + openIssuesListModal: () => void; + removeIssueFromCycle: (bridgeId: string) => void; + partialUpdateIssue: (formData: Partial, issueId: string) => void; + handleDeleteIssue: React.Dispatch>; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; + stateId: string | null; +}; + +const SingleModuleBoard: React.FC = ({ + properties, + groupedByIssues, + selectedGroup, + groupTitle, + createdBy, + bgColor, + openCreateIssueModal, + openIssuesListModal, + removeIssueFromCycle, + partialUpdateIssue, + handleDeleteIssue, + setPreloadedData, + stateId, +}) => { + // Collapse/Expand + const [show, setState] = useState(true); + + const { activeWorkspace } = useUser(); + + if (selectedGroup === "priority") + groupTitle === "high" + ? (bgColor = "#dc2626") + : groupTitle === "medium" + ? (bgColor = "#f97316") + : groupTitle === "low" + ? (bgColor = "#22c55e") + : (bgColor = "#ff0000"); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( +
      +
      +
      +
      +
      +

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

      + + {groupedByIssues[groupTitle].length} + +
      + + + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + } + }} + > + Create new + + openIssuesListModal()}> + Add an existing issue + + +
      +
      +
      + {groupedByIssues[groupTitle].map((childIssue, index: number) => { + const assignees = [ + ...(childIssue?.assignees_list ?? []), + ...(childIssue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find((p) => p.member.id === assignee)?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( + + ); + })} + + + + Add issue + + } + className="mt-1" + optionsPosition="left" + withoutBorder + > + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + } + }} + > + Create new + + openIssuesListModal()}> + Add an existing issue + + +
      +
      +
      + ); +}; + +export default SingleModuleBoard; diff --git a/apps/app/components/project/cycles/ConfirmCycleDeletion.tsx b/apps/app/components/project/cycles/confirm-cycle-deletion.tsx similarity index 100% rename from apps/app/components/project/cycles/ConfirmCycleDeletion.tsx rename to apps/app/components/project/cycles/confirm-cycle-deletion.tsx diff --git a/apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx b/apps/app/components/project/cycles/create-update-cycle-modal.tsx similarity index 96% rename from apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx rename to apps/app/components/project/cycles/create-update-cycle-modal.tsx index 3f3127082..5d52183b8 100644 --- a/apps/app/components/project/cycles/CreateUpdateCyclesModal.tsx +++ b/apps/app/components/project/cycles/create-update-cycle-modal.tsx @@ -195,6 +195,9 @@ const CreateUpdateCycleModal: React.FC = ({ isOpen, setIsOpen, data, proj placeholder="Enter start date" error={errors.start_date} register={register} + validations={{ + required: "Start date is required", + }} />
      @@ -206,6 +209,9 @@ const CreateUpdateCycleModal: React.FC = ({ isOpen, setIsOpen, data, proj placeholder="Enter end date" error={errors.end_date} register={register} + validations={{ + required: "End date is required", + }} />
      diff --git a/apps/app/components/project/cycles/list-view/index.tsx b/apps/app/components/project/cycles/list-view/index.tsx new file mode 100644 index 000000000..24f4c56cd --- /dev/null +++ b/apps/app/components/project/cycles/list-view/index.tsx @@ -0,0 +1,329 @@ +// react +import React from "react"; +// next +import Link from "next/link"; +// swr +import useSWR from "swr"; +// headless ui +import { Disclosure, Transition, Menu } from "@headlessui/react"; +// hooks +import useUser from "lib/hooks/useUser"; +// ui +import { CustomMenu, Spinner } from "ui"; +// icons +import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; +// fetch keys +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// constants +import { + addSpaceIfCamelCase, + classNames, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; +import workspaceService from "lib/services/workspace.service"; + +type Props = { + groupedByIssues: { + [key: string]: (IIssue & { bridge?: string })[]; + }; + properties: Properties; + selectedGroup: NestedKeyOf | null; + openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; + openIssuesListModal: () => void; + removeIssueFromCycle: (bridgeId: string) => void; + handleDeleteIssue: React.Dispatch>; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; +}; + +const CyclesListView: React.FC = ({ + groupedByIssues, + selectedGroup, + openCreateIssueModal, + openIssuesListModal, + properties, + removeIssueFromCycle, + handleDeleteIssue, + setPreloadedData, +}) => { + const { activeWorkspace, activeProject, states } = useUser(); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( +
      + {Object.keys(groupedByIssues).map((singleGroup) => { + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; + + return ( + + {({ open }) => ( +
      +
      + +
      + + + + {selectedGroup !== null ? ( +

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

      + ) : ( +

      All Issues

      + )} +

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

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

      No issues.

      + ) + ) : ( +
      + +
      + )} +
      +
      +
      +
      + + + Add issue + + } + optionsPosition="left" + withoutBorder + > + { + openCreateIssueModal(); + if (selectedGroup !== null) { + setPreloadedData({ + state: stateId !== null ? stateId : undefined, + [selectedGroup]: singleGroup, + actionType: "createIssue", + }); + } + }} + > + Create new + + openIssuesListModal()}> + Add an existing issue + + +
      +
      + )} +
      + ); + })} +
      + ); +}; + +export default CyclesListView; diff --git a/apps/app/components/project/cycles/stats-view/index.tsx b/apps/app/components/project/cycles/stats-view/index.tsx new file mode 100644 index 000000000..a72d57b13 --- /dev/null +++ b/apps/app/components/project/cycles/stats-view/index.tsx @@ -0,0 +1,56 @@ +// react +import { useState } from "react"; +// components +import SingleStat from "components/project/cycles/stats-view/single-stat"; +import ConfirmCycleDeletion from "components/project/cycles/confirm-cycle-deletion"; +// types +import { ICycle, SelectSprintType } from "types"; + +type Props = { + cycles: ICycle[]; + setCreateUpdateCycleModal: React.Dispatch>; + setSelectedCycle: React.Dispatch>; +}; + +const CycleStatsView: React.FC = ({ + cycles, + setCreateUpdateCycleModal, + setSelectedCycle, +}) => { + const [selectedCycleForDelete, setSelectedCycleForDelete] = useState(); + const [cycleDeleteModal, setCycleDeleteModal] = useState(false); + + const handleDeleteCycle = (cycle: ICycle) => { + setSelectedCycleForDelete({ ...cycle, actionType: "delete" }); + setCycleDeleteModal(true); + }; + + const handleEditCycle = (cycle: ICycle) => { + setSelectedCycle({ ...cycle, actionType: "edit" }); + setCreateUpdateCycleModal(true); + }; + + return ( + <> + + {cycles.map((cycle) => ( + handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + /> + ))} + + ); +}; + +export default CycleStatsView; diff --git a/apps/app/components/project/cycles/stats-view/single-stat.tsx b/apps/app/components/project/cycles/stats-view/single-stat.tsx new file mode 100644 index 000000000..dba327e3b --- /dev/null +++ b/apps/app/components/project/cycles/stats-view/single-stat.tsx @@ -0,0 +1,186 @@ +// react +import React, { useState } from "react"; +// next +import Link from "next/link"; +import Image from "next/image"; +// swr +import useSWR from "swr"; +// services +import cyclesService from "lib/services/cycles.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// ui +import { Button, CustomMenu } from "ui"; +// types +import { CycleIssueResponse, ICycle } from "types"; +// fetch-keys +import { CYCLE_ISSUES } from "constants/fetch-keys"; +import { groupBy, renderShortNumericDateFormat } from "constants/common"; +import { ArrowPathIcon, CheckIcon, UserIcon } from "@heroicons/react/24/outline"; +import { CalendarDaysIcon } from "@heroicons/react/20/solid"; +import { useRouter } from "next/router"; + +type Props = { + cycle: ICycle; + handleEditCycle: () => void; + handleDeleteCycle: () => void; +}; + +const stateGroupColours: { + [key: string]: string; +} = { + backlog: "#3f76ff", + unstarted: "#ff9e9e", + started: "#d687ff", + cancelled: "#ff5353", + completed: "#096e8d", +}; + +const SingleStat: React.FC = ({ cycle, handleEditCycle, handleDeleteCycle }) => { + const { activeWorkspace, activeProject } = useUser(); + + const router = useRouter(); + + const { data: cycleIssues } = useSWR( + activeWorkspace && activeProject && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null, + activeWorkspace && activeProject && cycle.id + ? () => + cyclesService.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycle.id as string) + : null + ); + const groupedIssues = { + backlog: [], + unstarted: [], + started: [], + cancelled: [], + completed: [], + ...groupBy(cycleIssues ?? [], "issue_details.state_detail.group"), + }; + + const startDate = new Date(cycle.start_date ?? ""); + const endDate = new Date(cycle.end_date ?? ""); + const today = new Date(); + + return ( + <> +
      +
      +
      +
      + + +

      {cycle.name}

      +
      + +
      +
      + endDate + ? "text-red-500 border-red-500" + : "text-green-500 border-green-500" + }`} + > + {today < startDate ? "Not started" : today > endDate ? "Over" : "Active"} + +
      + + Edit cycle + + Delete cycle permanently + + +
      +
      +
      +
      + + Cycle dates +
      +
      + {renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)} +
      +
      + + Created by +
      +
      + {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} + {cycle.owned_by.first_name} +
      +
      + + Active members +
      +
      +
      +
      + + +
      +
      +
      +

      PROGRESS

      +
      + {Object.keys(groupedIssues).map((group) => { + return ( +
      +
      + +
      {group}
      +
      +
      + + {groupedIssues[group].length}{" "} + + -{" "} + {cycleIssues && cycleIssues.length > 0 + ? `${Math.round( + (groupedIssues[group].length / cycleIssues.length) * 100 + )}%` + : "0%"} + + +
      +
      + ); + })} +
      +
      +
      +
      +
      + + ); +}; + +export default SingleStat; diff --git a/apps/app/components/project/issues/BoardView/SingleBoard.tsx b/apps/app/components/project/issues/BoardView/SingleBoard.tsx deleted file mode 100644 index 2c0426e93..000000000 --- a/apps/app/components/project/issues/BoardView/SingleBoard.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import React, { useState } from "react"; -// Next imports -import Link from "next/link"; -// React beautiful dnd -import { Draggable } from "react-beautiful-dnd"; -import StrictModeDroppable from "components/dnd/StrictModeDroppable"; -// common -import { - addSpaceIfCamelCase, - findHowManyDaysLeft, - renderShortNumericDateFormat, -} from "constants/common"; -// types -import { IIssue, Properties, NestedKeyOf } from "types"; -// icons -import { - ArrowsPointingInIcon, - ArrowsPointingOutIcon, - CalendarDaysIcon, - EllipsisHorizontalIcon, - PlusIcon, -} from "@heroicons/react/24/outline"; -import Image from "next/image"; -import { getPriorityIcon } from "constants/global"; - -type Props = { - selectedGroup: NestedKeyOf | null; - groupTitle: string; - groupedByIssues: { - [key: string]: IIssue[]; - }; - index: number; - setIsIssueOpen: React.Dispatch>; - properties: Properties; - setPreloadedData: React.Dispatch< - React.SetStateAction< - | (Partial & { - actionType: "createIssue" | "edit" | "delete"; - }) - | undefined - > - >; - bgColor?: string; - stateId: string | null; - createdBy: string | null; -}; - -const SingleBoard: React.FC = ({ - selectedGroup, - groupTitle, - groupedByIssues, - index, - setIsIssueOpen, - properties, - setPreloadedData, - bgColor = "#0f2b16", - stateId, - createdBy, -}) => { - // Collapse/Expand - const [show, setState] = useState(true); - - if (selectedGroup === "priority") - groupTitle === "high" - ? (bgColor = "#dc2626") - : groupTitle === "medium" - ? (bgColor = "#f97316") - : groupTitle === "low" - ? (bgColor = "#22c55e") - : (bgColor = "#ff0000"); - - return ( - - {(provided, snapshot) => ( -
      -
      -
      -
      - -
      - -

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

      - - {groupedByIssues[groupTitle].length} - -
      -
      - -
      - - -
      -
      - - {(provided, snapshot) => ( -
      - {groupedByIssues[groupTitle].map((childIssue, index: number) => ( - - {(provided, snapshot) => ( - - -
      - {properties.key && ( -
      - {childIssue.project_detail?.identifier}-{childIssue.sequence_id} -
      - )} -
      - {childIssue.name} -
      -
      - {properties.priority && ( -
      - {/* {getPriorityIcon(childIssue.priority ?? "")} */} - {childIssue.priority} -
      - )} - {properties.state && ( -
      - - {addSpaceIfCamelCase(childIssue.state_detail.name)} -
      - )} - {properties.start_date && ( -
      - - {childIssue.start_date - ? renderShortNumericDateFormat(childIssue.start_date) - : "N/A"} -
      - )} - {properties.target_date && ( -
      - - {childIssue.target_date - ? renderShortNumericDateFormat(childIssue.target_date) - : "N/A"} - {childIssue.target_date && ( - - {childIssue.target_date < new Date().toISOString() - ? `Target date has passed by ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : findHowManyDaysLeft(childIssue.target_date) <= 3 - ? `Target date is in ${findHowManyDaysLeft( - childIssue.target_date - )} days` - : "Target date"} - - )} -
      - )} - {properties.assignee && ( -
      - {childIssue?.assignee_details?.length > 0 ? ( - childIssue?.assignee_details?.map( - (assignee, index: number) => ( -
      - {assignee.avatar && assignee.avatar !== "" ? ( -
      - {assignee.name} -
      - ) : ( -
      - {assignee.first_name.charAt(0)} -
      - )} -
      - ) - ) - ) : ( - No assignee. - )} -
      - )} -
      -
      -
      - - )} -
      - ))} - {provided.placeholder} - -
      - )} -
      -
      -
      - )} -
      - ); -}; - -export default SingleBoard; diff --git a/apps/app/components/project/issues/BoardView/index.tsx b/apps/app/components/project/issues/BoardView/index.tsx index 4881f22ed..33ee73cf6 100644 --- a/apps/app/components/project/issues/BoardView/index.tsx +++ b/apps/app/components/project/issues/BoardView/index.tsx @@ -14,15 +14,14 @@ import useUser from "lib/hooks/useUser"; // fetching keys import { STATE_LIST } from "constants/fetch-keys"; // components -import SingleBoard from "components/project/issues/BoardView/SingleBoard"; +import SingleBoard from "components/project/issues/BoardView/single-board"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; -import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; +import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal"; // ui import { Spinner } from "ui"; // types import type { IState, IIssue, Properties, NestedKeyOf, IProjectMember } from "types"; -import ConfirmIssueDeletion from "../ConfirmIssueDeletion"; -import { TrashIcon } from "@heroicons/react/24/outline"; +import ConfirmIssueDeletion from "../confirm-issue-deletion"; type Props = { properties: Properties; @@ -31,9 +30,18 @@ type Props = { [key: string]: IIssue[]; }; members: IProjectMember[] | undefined; + handleDeleteIssue: React.Dispatch>; + partialUpdateIssue: (formData: Partial, issueId: string) => void; }; -const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues, members }) => { +const BoardView: React.FC = ({ + properties, + selectedGroup, + groupedByIssues, + members, + handleDeleteIssue, + partialUpdateIssue, +}) => { const [isOpen, setIsOpen] = useState(false); const [isIssueOpen, setIsIssueOpen] = useState(false); @@ -217,6 +225,8 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues ? states?.find((s) => s.name === singleGroup)?.color : undefined } + handleDeleteIssue={handleDeleteIssue} + partialUpdateIssue={partialUpdateIssue} /> ))}
      diff --git a/apps/app/components/project/issues/BoardView/single-board.tsx b/apps/app/components/project/issues/BoardView/single-board.tsx new file mode 100644 index 000000000..353c02a82 --- /dev/null +++ b/apps/app/components/project/issues/BoardView/single-board.tsx @@ -0,0 +1,240 @@ +// react +import React, { useState } from "react"; +// swr +import useSWR from "swr"; +// react-beautiful-dnd +import { Draggable } from "react-beautiful-dnd"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +// services +import workspaceService from "lib/services/workspace.service"; +// hooks +import useUser from "lib/hooks/useUser"; +// icons +import { + ArrowsPointingInIcon, + ArrowsPointingOutIcon, + EllipsisHorizontalIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; +import { addSpaceIfCamelCase } from "constants/common"; +import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +// types +import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types"; +import SingleIssue from "components/common/board-view/single-issue"; + +type Props = { + selectedGroup: NestedKeyOf | null; + groupTitle: string; + groupedByIssues: { + [key: string]: IIssue[]; + }; + index: number; + setIsIssueOpen: React.Dispatch>; + properties: Properties; + setPreloadedData: React.Dispatch< + React.SetStateAction< + | (Partial & { + actionType: "createIssue" | "edit" | "delete"; + }) + | undefined + > + >; + bgColor?: string; + stateId: string | null; + createdBy: string | null; + handleDeleteIssue: React.Dispatch>; + partialUpdateIssue: (formData: Partial, childIssueId: string) => void; +}; + +const SingleBoard: React.FC = ({ + selectedGroup, + groupTitle, + groupedByIssues, + index, + setIsIssueOpen, + properties, + setPreloadedData, + bgColor = "#0f2b16", + stateId, + createdBy, + handleDeleteIssue, + partialUpdateIssue, +}) => { + // Collapse/Expand + const [show, setShow] = useState(true); + + const { activeWorkspace } = useUser(); + + if (selectedGroup === "priority") + groupTitle === "high" + ? (bgColor = "#dc2626") + : groupTitle === "medium" + ? (bgColor = "#f97316") + : groupTitle === "low" + ? (bgColor = "#22c55e") + : (bgColor = "#ff0000"); + + const { data: people } = useSWR( + activeWorkspace ? WORKSPACE_MEMBERS : null, + activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null + ); + + return ( + + {(provided, snapshot) => ( +
      +
      +
      +
      + +
      +

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

      + + {groupedByIssues[groupTitle].length} + +
      +
      + +
      + + +
      +
      + + {(provided, snapshot) => ( +
      + {groupedByIssues[groupTitle].map((childIssue, index: number) => { + const assignees = [ + ...(childIssue?.assignees_list ?? []), + ...(childIssue?.assignees ?? []), + ]?.map((assignee) => { + const tempPerson = people?.find((p) => p.member.id === assignee)?.member; + + return { + avatar: tempPerson?.avatar, + first_name: tempPerson?.first_name, + email: tempPerson?.email, + }; + }); + + return ( + + {(provided, snapshot) => ( +
      + +
      + )} +
      + ); + })} + {provided.placeholder} + +
      + )} +
      +
      +
      + )} +
      + ); +}; + +export default SingleBoard; diff --git a/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx new file mode 100644 index 000000000..8b884788c --- /dev/null +++ b/apps/app/components/project/issues/BoardView/state/confirm-state-delete.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef, useState } from "react"; +// swr +import { mutate } from "swr"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import stateServices from "lib/services/state.service"; +// fetch api +import { STATE_LIST } from "constants/fetch-keys"; +// hooks +import useUser from "lib/hooks/useUser"; +// common +import { groupBy } from "constants/common"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { Button } from "ui"; + +// types +import type { IState } from "types"; +type Props = { + isOpen: boolean; + onClose: () => void; + data: IState | null; +}; + +const ConfirmStateDeletion: React.FC = ({ isOpen, onClose, data }) => { + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const [issuesWithThisStateExist, setIssuesWithThisStateExist] = useState(true); + + const { activeWorkspace, issues } = useUser(); + + const cancelButtonRef = useRef(null); + + const handleClose = () => { + onClose(); + setIsDeleteLoading(false); + }; + + const handleDeletion = async () => { + setIsDeleteLoading(true); + if (!data || !activeWorkspace || issuesWithThisStateExist) return; + await stateServices + .deleteState(activeWorkspace.slug, data.project, data.id) + .then(() => { + mutate( + STATE_LIST(data.project), + (prevData) => prevData?.filter((state) => state.id !== data?.id), + false + ); + handleClose(); + }) + .catch((error) => { + console.log(error); + setIsDeleteLoading(false); + }); + }; + + const groupedIssues = groupBy(issues?.results ?? [], "state"); + + useEffect(() => { + if (data) setIssuesWithThisStateExist(!!groupedIssues[data.id]); + }, [groupedIssues, data]); + + return ( + + + +
      + + +
      +
      + + +
      +
      +
      +
      +
      + + Delete State + +
      +

      + Are you sure you want to delete state - {`"`} + {data?.name} + {`"`} ? All of the data related to the state will be permanently removed. + This action cannot be undone. +

      +
      +
      + {issuesWithThisStateExist && ( +

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

      + )} +
      +
      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      +
      + ); +}; + +export default ConfirmStateDeletion; diff --git a/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx new file mode 100644 index 000000000..22f224c09 --- /dev/null +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-inline.tsx @@ -0,0 +1,209 @@ +import React, { useEffect } from "react"; +// swr +import { mutate } from "swr"; +// react hook form +import { useForm, Controller } from "react-hook-form"; +// react color +import { TwitterPicker } from "react-color"; +// headless +import { Popover, Transition } from "@headlessui/react"; +// constants +import { GROUP_CHOICES } from "constants/"; +import { STATE_LIST } from "constants/fetch-keys"; +// services +import stateService from "lib/services/state.service"; +// ui +import { Button, Input, Select, Spinner } from "ui"; +// types +import type { IState } from "types"; + +type Props = { + workspaceSlug?: string; + projectId?: string; + data: IState | null; + onClose: () => void; + selectedGroup: StateGroup | null; +}; + +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; + +const defaultValues: Partial = { + name: "", + color: "#000000", + group: "backlog", +}; + +export const CreateUpdateStateInline: React.FC = ({ + workspaceSlug, + projectId, + data, + onClose, + selectedGroup, +}) => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + watch, + reset, + control, + } = useForm({ + defaultValues, + }); + + const handleClose = () => { + onClose(); + reset({ name: "", color: "#000000", group: "backlog" }); + }; + + const onSubmit = async (formData: IState) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + const payload: IState = { + ...formData, + }; + if (!data) { + await stateService + .createState(workspaceSlug, projectId, { ...payload }) + .then((res) => { + mutate(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res], false); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } else { + await stateService + .updateState(workspaceSlug, projectId, data.id, { + ...payload, + }) + .then((res) => { + mutate( + STATE_LIST(projectId), + (prevData) => { + const newData = prevData?.map((item) => { + if (item.id === res.id) { + return res; + } + return item; + }); + return newData; + }, + false + ); + handleClose(); + }) + .catch((err) => { + Object.keys(err).map((key) => { + setError(key as keyof IState, { + message: err[key].join(", "), + }); + }); + }); + } + }; + + useEffect(() => { + if (data === null) return; + reset(data); + }, [data, reset]); + + useEffect(() => { + if (!data) + reset({ + ...defaultValues, + group: selectedGroup ?? "backlog", + }); + }, [selectedGroup, data, reset]); + + return ( +
      +
      + + {({ open }) => ( + <> + + {watch("color") && watch("color") !== "" && ( + + )} + + + + + ( + onChange(value.hex)} /> + )} + /> + + + + )} + +
      + + {data && ( + + + +
      + ); +}; diff --git a/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx b/apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx similarity index 91% rename from apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx rename to apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx index 3ba149820..4cd5a1367 100644 --- a/apps/app/components/project/issues/BoardView/state/CreateUpdateStateModal.tsx +++ b/apps/app/components/project/issues/BoardView/state/create-update-state-modal.tsx @@ -11,10 +11,12 @@ import { Dialog, Popover, Transition } from "@headlessui/react"; import stateService from "lib/services/state.service"; // fetch keys import { STATE_LIST } from "constants/fetch-keys"; +// constants +import { GROUP_CHOICES } from "constants/"; // hooks import useUser from "lib/hooks/useUser"; // ui -import { Button, Input, TextArea } from "ui"; +import { Button, Input, Select, TextArea } from "ui"; // icons import { ChevronDownIcon } from "@heroicons/react/24/outline"; @@ -31,6 +33,7 @@ const defaultValues: Partial = { name: "", description: "", color: "#000000", + group: "backlog", }; const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, handleClose }) => { @@ -161,6 +164,22 @@ const CreateUpdateStateModal: React.FC = ({ isOpen, data, projectId, hand }} />
      +
      + - ) : null} - - - {activeProject?.identifier}-{issue.sequence_id} - {" "} - {issue.name} - - )} - - ))} -
    -
  • - )} - - - {query !== "" && filteredIssues.length === 0 && ( -
    -
    - )} - - {multiple ? ( -
    - - -
    - ) : null} - - - - -
    - - ); -}; - -export default IssuesListModal; diff --git a/apps/app/components/project/issues/ListView/index.tsx b/apps/app/components/project/issues/ListView/index.tsx deleted file mode 100644 index 78d5ab68b..000000000 --- a/apps/app/components/project/issues/ListView/index.tsx +++ /dev/null @@ -1,414 +0,0 @@ -// react -import React, { useState } from "react"; -// next -import Link from "next/link"; -import Image from "next/image"; -// swr -import useSWR, { mutate } from "swr"; -// ui -import { Listbox, Transition } from "@headlessui/react"; -// icons -import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; -// types -import { IIssue, IssueResponse, NestedKeyOf, Properties } from "types"; -// hooks -import useUser from "lib/hooks/useUser"; -// fetch keys -import { PRIORITIES } from "constants/"; -import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; -// services -import issuesServices from "lib/services/issues.service"; -import workspaceService from "lib/services/workspace.service"; -// constants -import { addSpaceIfCamelCase, classNames, renderShortNumericDateFormat } from "constants/common"; - -// types -type Props = { - properties: Properties; - groupedByIssues: any; - selectedGroup: NestedKeyOf | null; - setSelectedIssue: any; - handleDeleteIssue: React.Dispatch>; -}; - -const ListView: React.FC = ({ - properties, - groupedByIssues, - selectedGroup, - setSelectedIssue, - handleDeleteIssue, -}) => { - const { activeWorkspace, activeProject, states } = useUser(); - - const partialUpdateIssue = (formData: Partial, issueId: string) => { - if (!activeWorkspace || !activeProject) return; - issuesServices - .patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData) - .then((response) => { - mutate( - PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id), - (prevData) => ({ - ...(prevData as IssueResponse), - results: - prevData?.results.map((issue) => (issue.id === response.id ? response : issue)) ?? [], - }), - false - ); - }) - .catch((error) => { - console.log(error); - }); - }; - - const { data: people } = useSWR( - activeWorkspace ? WORKSPACE_MEMBERS : null, - activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null - ); - - return ( -
    - {Object.keys(groupedByIssues).map((singleGroup) => ( -
    -
    -
    - - {selectedGroup !== null ? ( - - - - - - ) : ( - - - - - - )} - - {groupedByIssues[singleGroup].length > 0 - ? groupedByIssues[singleGroup].map((issue: IIssue, index: number) => { - const assignees = [ - ...(issue?.assignees_list ?? []), - ...(issue?.assignees ?? []), - ]?.map( - (assignee) => people?.find((p) => p.member.id === assignee)?.member.email - ); - - return ( - - - {Object.keys(properties).map( - (key) => - properties[key as keyof Properties] && ( - - {(key as keyof Properties) === "key" ? ( - - ) : (key as keyof Properties) === "priority" ? ( - - ) : (key as keyof Properties) === "assignee" ? ( - - ) : (key as keyof Properties) === "state" ? ( - - ) : (key as keyof Properties) === "target_date" ? ( - - ) : ( - - )} - - ) - )} - - - ); - }) - : null} - -
    -
    - {selectedGroup === "state_detail.name" ? ( - s.name === singleGroup)?.color, - }} - > - ) : null} - {singleGroup === null || singleGroup === "null" - ? selectedGroup === "priority" && "No priority" - : addSpaceIfCamelCase(singleGroup)} - - {groupedByIssues[singleGroup as keyof IIssue].length} - -
    -
    - ALL ISSUES - - {groupedByIssues[singleGroup as keyof IIssue].length} - -
    - - {issue.name} - - - {activeProject?.identifier}-{issue.sequence_id} - - { - partialUpdateIssue({ priority: data }, issue.id); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
    - - - {issue.priority ?? "None"} - - - - - - {PRIORITIES?.map((priority) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer capitalize select-none px-3 py-2" - ) - } - value={priority} - > - {priority} - - ))} - - -
    - - )} -
    -
    - { - const newData = issue.assignees ?? []; - if (newData.includes(data)) { - newData.splice(newData.indexOf(data), 1); - } else { - newData.push(data); - } - partialUpdateIssue( - { assignees_list: newData }, - issue.id - ); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
    - - {() => { - if (assignees.length > 0) - return ( - <> - {assignees.map((assignee, index) => ( -
    - {assignee} -
    - ))} - - ); - else return None; - }} -
    - - - - {people?.map((person) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none px-3 py-2" - ) - } - value={person.member.id} - > -
    - {person.member.avatar && - person.member.avatar !== "" ? ( -
    - avatar -
    - ) : ( -

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

    - )} -

    {person.member.first_name}

    -
    -
    - ))} -
    -
    -
    - - )} -
    -
    - { - partialUpdateIssue({ state: data }, issue.id); - }} - className="flex-shrink-0" - > - {({ open }) => ( - <> -
    - - - {addSpaceIfCamelCase(issue.state_detail.name)} - - - - - - {states?.map((state) => ( - - classNames( - active ? "bg-indigo-50" : "bg-white", - "cursor-pointer select-none px-3 py-2" - ) - } - value={state.id} - > - {addSpaceIfCamelCase(state.name)} - - ))} - - -
    - - )} -
    -
    - {issue.target_date - ? renderShortNumericDateFormat(issue.target_date) - : "-"} - - {issue[key as keyof IIssue] ?? - (issue[key as keyof IIssue] as any)?.name ?? - "None"} - -
    - - -
    -
    -
    -
    -
    - ))} -
    - ); -}; - -export default ListView; diff --git a/apps/app/components/project/issues/ConfirmIssueDeletion.tsx b/apps/app/components/project/issues/confirm-issue-deletion.tsx similarity index 79% rename from apps/app/components/project/issues/ConfirmIssueDeletion.tsx rename to apps/app/components/project/issues/confirm-issue-deletion.tsx index f1476c682..613048ef5 100644 --- a/apps/app/components/project/issues/ConfirmIssueDeletion.tsx +++ b/apps/app/components/project/issues/confirm-issue-deletion.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; // swr import { mutate } from "swr"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // fetching keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { CYCLE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; // services import issueServices from "lib/services/issues.service"; // hooks @@ -26,7 +26,7 @@ type Props = { const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const { activeWorkspace } = useUser(); + const { activeWorkspace, activeProject } = useUser(); const { setToastAlert } = useToast(); @@ -55,6 +55,7 @@ const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => }, false ); + mutate(CYCLE_ISSUES(data.issue_cycle?.id ?? "")); setToastAlert({ title: "Success", type: "success", @@ -70,7 +71,7 @@ const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => return ( - + = ({ isOpen, handleClose, data }) =>
    -
    -
    -
    - - Delete Issue +
    +
    +
    + + Are you sure you want to delete {`"`} + {activeProject?.identifier}-{data?.sequence_id} - {data?.name}?{`"`}

    - Are you sure you want to delete issue - {`"`} - {data?.name} - {`"`} ? All of the data related to the issue will be permanently removed. - This action cannot be undone. + All of the data related to the issue will be permanently removed. This + action cannot be undone.

    diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx b/apps/app/components/project/issues/create-update-issue-modal/index.tsx similarity index 85% rename from apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx rename to apps/app/components/project/issues/create-update-issue-modal/index.tsx index 14032c6c6..07985f9d1 100644 --- a/apps/app/components/project/issues/CreateUpdateIssueModal/index.tsx +++ b/apps/app/components/project/issues/create-update-issue-modal/index.tsx @@ -1,18 +1,12 @@ import React, { useEffect, useState } from "react"; // next -import { useRouter } from "next/router"; import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useRouter } from "next/router"; // swr import { mutate } from "swr"; // react hook form import { Controller, useForm } from "react-hook-form"; -// fetching keys -import { - PROJECT_ISSUES_DETAILS, - PROJECT_ISSUES_LIST, - CYCLE_ISSUES, - USER_ISSUE, -} from "constants/fetch-keys"; // headless import { Dialog, Menu, Transition } from "@headlessui/react"; // services @@ -21,23 +15,34 @@ import issuesServices from "lib/services/issues.service"; import useUser from "lib/hooks/useUser"; import useToast from "lib/hooks/useToast"; // ui -import { Button, Input, TextArea } from "ui"; -// commons -import { renderDateFormat, cosineSimilarity } from "constants/common"; -// components -import SelectState from "./SelectState"; -import SelectCycles from "./SelectCycles"; -import SelectLabels from "./SelectLabels"; -import SelectProject from "./SelectProject"; -import SelectPriority from "./SelectPriority"; -import SelectAssignee from "./SelectAssignee"; -import SelectParent from "./SelectParentIssue"; -import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal"; -import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; - -// types -import type { IIssue, IssueResponse, CycleIssueResponse } from "types"; +import { Button, TextArea } from "ui"; +// icons import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline"; +// components +import SelectState from "components/project/issues/create-update-issue-modal/select-state"; +import SelectCycles from "components/project/issues/create-update-issue-modal/select-cycle"; +import SelectLabels from "components/project/issues/create-update-issue-modal/select-labels"; +import SelectProject from "components/project/issues/create-update-issue-modal/select-project"; +import SelectPriority from "components/project/issues/create-update-issue-modal/select-priority"; +import SelectAssignee from "components/project/issues/create-update-issue-modal/select-assignee"; +import SelectParent from "components/project/issues/create-update-issue-modal/select-parent-issue"; +import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal"; +import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; +// types +import type { IIssue, IssueResponse } from "types"; +// fetch keys +import { + PROJECT_ISSUES_DETAILS, + PROJECT_ISSUES_LIST, + CYCLE_ISSUES, + USER_ISSUE, +} from "constants/fetch-keys"; +// common +import { renderDateFormat, cosineSimilarity } from "constants/common"; + +const RichTextEditor = dynamic(() => import("components/lexical/editor"), { + ssr: false, +}); type Props = { isOpen: boolean; @@ -78,10 +83,6 @@ const CreateUpdateIssuesModal: React.FC = ({ // setIssueDescriptionValue(value); // }; - const RichTextEditor = dynamic(() => import("components/lexical/editor"), { - ssr: false, - }); - const router = useRouter(); const handleClose = () => { @@ -114,36 +115,18 @@ const CreateUpdateIssuesModal: React.FC = ({ }, 500); }; - const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => { + const addIssueToCycle = async (issueId: string, cycleId: string) => { if (!activeWorkspace || !activeProject) return; await issuesServices - .addIssueToSprint(activeWorkspace.slug, activeProject.id, sprintId, { - issue: issueId, + .addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, { + issues: [issueId], }) .then((res) => { - mutate( - CYCLE_ISSUES(sprintId), - (prevData) => { - const targetResponse = prevData?.find((t) => t.cycle === sprintId); - if (targetResponse) { - targetResponse.issue_details = issueDetail; - return prevData; - } else { - return [ - ...(prevData ?? []), - { - cycle: sprintId, - issue_details: issueDetail, - } as CycleIssueResponse, - ]; - } - }, - false - ); + mutate(CYCLE_ISSUES(cycleId)); if (isUpdatingSingleIssue) { mutate( PROJECT_ISSUES_DETAILS, - (prevData) => ({ ...(prevData as IIssue), sprints: sprintId }), + (prevData) => ({ ...(prevData as IIssue), sprints: cycleId }), false ); } else @@ -153,7 +136,7 @@ const CreateUpdateIssuesModal: React.FC = ({ return { ...(prevData as IssueResponse), results: (prevData?.results ?? []).map((issue) => { - if (issue.id === res.id) return { ...issue, sprints: sprintId }; + if (issue.id === res.id) return { ...issue, sprints: cycleId }; return issue; }), }; @@ -176,6 +159,7 @@ const CreateUpdateIssuesModal: React.FC = ({ const payload: Partial = { ...formData, target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null, + // description: formData.description ? JSON.parse(formData.description) : null, }; if (!data) { await issuesServices @@ -185,7 +169,7 @@ const CreateUpdateIssuesModal: React.FC = ({ mutate(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id)); if (formData.sprints && formData.sprints !== null) { - await addIssueToSprint(res.id, formData.sprints, formData); + await addIssueToCycle(res.id, formData.sprints); } handleClose(); resetForm(); @@ -225,7 +209,7 @@ const CreateUpdateIssuesModal: React.FC = ({ false ); if (formData.sprints && formData.sprints !== null) { - await addIssueToSprint(res.id, formData.sprints, formData); + await addIssueToCycle(res.id, formData.sprints); } handleClose(); resetForm(); @@ -261,8 +245,6 @@ const CreateUpdateIssuesModal: React.FC = ({ return () => setMostSimilarIssue(undefined); }, []); - // console.log(watch("parent")); - return ( <> {activeProject && ( @@ -340,27 +322,30 @@ const CreateUpdateIssuesModal: React.FC = ({ {mostSimilarIssue && (

    - Did you mean{" "} - - ? + + Did you mean + + { + issues?.results.find((i) => i.id === mostSimilarIssue) + ?.project_detail.identifier + } + - + { + issues?.results.find((i) => i.id === mostSimilarIssue) + ?.sequence_id + } + :{" "} + { + issues?.results.find((i) => i.id === mostSimilarIssue) + ?.name + }{" "} + + ? + + {" "}

    - -
    -
    -
    - {sidebarSections.map((section, index) => ( -
    - {section.map((item) => ( -
    -
    - -

    {item.label}

    -
    -
    - {item.name === "target_date" ? ( - ( - { - submitChanges({ target_date: e.target.value }); - onChange(e.target.value); - }} - className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full" - /> - )} - /> - ) : item.modal ? ( - ( - <> - item.setIsOpen && item.setIsOpen(false)} - onChange={(val) => { - console.log(val); - // submitChanges({ [item.name]: val }); - onChange(val); - }} - issues={item?.issuesList ?? []} - title={`Select ${item.label}`} - multiple={item.canSelectMultipleOptions} - value={value} - /> - - - )} - /> - ) : ( - ( - { - if (item.name === "cycle") handleCycleChange(value); - else submitChanges({ [item.name]: value }); - }} - className="flex-shrink-0" - > - {({ open }) => ( -
    - - - {value - ? Array.isArray(value) - ? value - .map( - (i: any) => - item.options?.find((option) => option.value === i) - ?.label - ) - .join(", ") || item.label - : item.options?.find((option) => option.value === value) - ?.label - : "None"} - - - - - - -
    - {item.options ? ( - item.options.length > 0 ? ( - item.options.map((option) => ( - - `${ - active || selected - ? "text-white bg-theme" - : "text-gray-900" - } ${ - item.label === "Priority" && "capitalize" - } cursor-pointer select-none relative p-2 rounded-md truncate` - } - value={option.value} - > - {option.label} - - )) - ) : ( -
    No {item.label}s found
    - ) - ) : ( - - )} -
    -
    -
    -
    - )} -
    - )} - /> - )} -
    -
    - ))} -
    - ))} -
    -
    -
    Add new label
    -
    -
    - - {({ open }) => ( - <> - - {watch("colour") && watch("colour") !== "" && ( - - )} - - - - - - ( - onChange(value.hex)} /> - )} - /> - - - - )} - -
    - - -
    -
    -
    - -

    Label

    -
    -
    - ( - submitChanges({ labels_list: value })} - className="flex-shrink-0" - > - {({ open }) => ( - <> - Label -
    - - - {value && value.length > 0 - ? value - .map( - (i: string) => - issueLabels?.find((option) => option.id === i)?.name - ) - .join(", ") - : "None"} - - - - - - -
    - {issueLabels ? ( - issueLabels.length > 0 ? ( - issueLabels.map((label: IIssueLabels) => ( - - `${ - active || selected - ? "text-white bg-theme" - : "text-gray-900" - } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` - } - value={label.id} - > - - {label.name} - - )) - ) : ( -
    No labels found
    - ) - ) : ( - - )} -
    -
    -
    -
    - - )} -
    - )} - /> -
    -
    -
    -
    - ); -}; - -export default IssueDetailSidebar; diff --git a/apps/app/components/project/issues/issue-detail/activity/index.tsx b/apps/app/components/project/issues/issue-detail/activity/index.tsx index 2df8c5092..9b105d2d3 100644 --- a/apps/app/components/project/issues/issue-detail/activity/index.tsx +++ b/apps/app/components/project/issues/issue-detail/activity/index.tsx @@ -1,5 +1,16 @@ +import React from "react"; // next +import { useRouter } from "next/router"; import Image from "next/image"; +// swr +import useSWR from "swr"; +// constants +import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { addSpaceIfCamelCase, timeAgo } from "constants/common"; +// services +import issuesServices from "lib/services/issues.service"; +// hooks +import useUser from "lib/hooks/useUser"; // ui import { Spinner } from "ui"; // icons @@ -12,27 +23,37 @@ import { } from "@heroicons/react/24/outline"; // types import { IssueResponse, IState } from "types"; -// constants -import { addSpaceIfCamelCase, timeAgo } from "constants/common"; - -type Props = { - issueActivities: any[] | undefined; - states: IState[] | undefined; - issues: IssueResponse | undefined; -}; const activityIcons: { [key: string]: JSX.Element; } = { - state: , - priority: , - name: , - description: , - target_date: , - parent: , + state: , + priority: , + name: , + description: , + target_date: , + parent: , }; -const IssueActivitySection: React.FC = ({ issueActivities, states, issues }) => { +const IssueActivitySection: React.FC = () => { + const router = useRouter(); + + const { issueId, projectId } = router.query; + + const { activeWorkspace, states, issues } = useUser(); + + const { data: issueActivities } = useSWR( + activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null, + activeWorkspace && projectId && issueId + ? () => + issuesServices.getIssueActivities( + activeWorkspace.slug, + projectId as string, + issueId as string + ) + : null + ); + return ( <> {issueActivities ? ( diff --git a/apps/app/components/command-palette/addAsSubIssue.tsx b/apps/app/components/project/issues/issue-detail/add-as-sub-issue.tsx similarity index 100% rename from apps/app/components/command-palette/addAsSubIssue.tsx rename to apps/app/components/project/issues/issue-detail/add-as-sub-issue.tsx diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx index e911ae306..90c3b0bed 100644 --- a/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx +++ b/apps/app/components/project/issues/issue-detail/comment/IssueCommentCard.tsx @@ -1,16 +1,12 @@ import React, { useEffect, useState } from "react"; // next import Image from "next/image"; -// swr -import { mutate } from "swr"; // headless ui import { Menu } from "@headlessui/react"; // react hook form import { useForm } from "react-hook-form"; // hooks import useUser from "lib/hooks/useUser"; -// fetch keys -import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys"; // common import { timeAgo } from "constants/common"; // ui @@ -42,16 +38,6 @@ const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion const onEnter = (formData: IIssueComment) => { if (isSubmitting) return; - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => { - const newData = prevData ?? []; - const index = newData.findIndex((comment) => comment.id === formData.id); - newData[index] = formData; - return [...newData]; - }, - false - ); setIsEditing(false); onSubmit(formData); }; @@ -155,11 +141,6 @@ const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion className="w-full text-left py-2 pl-2" type="button" onClick={() => { - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => (prevData ?? []).filter((c) => c.id !== comment.id), - false - ); handleCommentDeletion(comment.id); }} > diff --git a/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx b/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx index fe07421f1..d5f4b62ce 100644 --- a/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx +++ b/apps/app/components/project/issues/issue-detail/comment/IssueCommentSection.tsx @@ -1,33 +1,28 @@ import React from "react"; +// router +import { useRouter } from "next/router"; // swr -import { mutate } from "swr"; +import useSWR from "swr"; // react hook form import { useForm } from "react-hook-form"; // services import issuesServices from "lib/services/issues.service"; // fetch keys import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys"; +// hooks +import useUser from "lib/hooks/useUser"; // components import CommentCard from "components/project/issues/issue-detail/comment/IssueCommentCard"; // ui import { TextArea, Button, Spinner } from "ui"; // types import type { IIssueComment } from "types"; -// icons -import UploadingIcon from "public/animated-icons/uploading.json"; - -type Props = { - comments?: IIssueComment[]; - workspaceSlug: string; - projectId: string; - issueId: string; -}; const defaultValues: Partial = { comment: "", }; -const IssueCommentSection: React.FC = ({ comments, issueId, projectId, workspaceSlug }) => { +const IssueCommentSection: React.FC = () => { const { register, handleSubmit, @@ -36,16 +31,31 @@ const IssueCommentSection: React.FC = ({ comments, issueId, projectId, wo reset, } = useForm({ defaultValues }); + const router = useRouter(); + + let { issueId, projectId } = router.query; + + const { activeWorkspace } = useUser(); + + const { data: comments, mutate } = useSWR( + activeWorkspace && projectId && issueId ? PROJECT_ISSUES_COMMENTS(issueId as string) : null, + activeWorkspace && projectId && issueId + ? () => + issuesServices.getIssueComments( + activeWorkspace.slug, + projectId as string, + issueId as string + ) + : null + ); + const onSubmit = async (formData: IIssueComment) => { + if (!activeWorkspace || !projectId || !issueId || isSubmitting) return; await issuesServices - .createIssueComment(workspaceSlug, projectId, issueId, formData) + .createIssueComment(activeWorkspace.slug, projectId as string, issueId as string, formData) .then((response) => { console.log(response); - mutate( - PROJECT_ISSUES_COMMENTS, - (prevData) => [...(prevData ?? []), response], - false - ); + mutate((prevData) => [response, ...(prevData ?? [])]); reset(defaultValues); }) .catch((error) => { @@ -54,59 +64,40 @@ const IssueCommentSection: React.FC = ({ comments, issueId, projectId, wo }; const onCommentUpdate = async (comment: IIssueComment) => { + if (!activeWorkspace || !projectId || !issueId || isSubmitting) return; await issuesServices - .patchIssueComment(workspaceSlug, projectId, issueId, comment.id, comment) + .patchIssueComment( + activeWorkspace.slug, + projectId as string, + issueId as string, + comment.id, + comment + ) .then((response) => { - console.log(response); + mutate((prevData) => { + const updatedComments = prevData?.map((c) => { + if (c.id === comment.id) { + return comment; + } + return c; + }); + return updatedComments; + }); }); }; const onCommentDelete = async (commentId: string) => { + if (!activeWorkspace || !projectId || !issueId || isSubmitting) return; await issuesServices - .deleteIssueComment(workspaceSlug, projectId, issueId, commentId) + .deleteIssueComment(activeWorkspace.slug, projectId as string, issueId as string, commentId) .then((response) => { + mutate((prevData) => (prevData ?? []).filter((c) => c.id !== commentId)); console.log(response); }); }; return (
    -
    -
    -
    -