diff --git a/.deepsource.toml b/.deepsource.toml index 85de1a5e8..2b40af672 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,5 +1,11 @@ version = 1 +exclude_patterns = [ + "bin/**", + "**/node_modules/", + "**/*.min.js" +] + [[analyzers]] name = "shell" diff --git a/.dockerignore b/.dockerignore index 45ff21c4f..6d52ca7c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,16 @@ *.pyc .env venv -node_modules -npm-debug.log \ No newline at end of file +node_modules/ +**/node_modules/ +npm-debug.log +.next/ +**/.next/ +.turbo/ +**/.turbo/ +build/ +**/build/ +out/ +**/out/ +dist/ +**/dist/ \ No newline at end of file diff --git a/.env.example b/.env.example index 082aa753b..90070de19 100644 --- a/.env.example +++ b/.env.example @@ -21,15 +21,15 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Settings related to Docker -DOCKERIZED=1 +DOCKERIZED=1 # deprecated + # set to 1 If using the pre-configured minio setup USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 - diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 58c404e37..d25154b15 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,62 +1,30 @@ - name: Branch Build on: pull_request: - types: + types: - closed - branches: + branches: - master - - release + - preview - qa - develop + release: + types: [released, prereleased] env: - TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }} jobs: - branch_build_and_push: - if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }} + branch_build_setup: + if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }} name: Build-Push Web/Space/API/Proxy Docker Image runs-on: ubuntu-20.04 steps: - name: Check out the repo uses: actions/checkout@v3.3.0 - - # - name: Set Target Branch Name on PR close - # if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }} - # run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV - # - name: Set Target Branch Name on other than PR close - # if: ${{ github.event_name == 'push' }} - # run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV - - - uses: ASzc/change-string-case-action@v2 - id: gh_branch_upper_lower - with: - string: ${{env.TARGET_BRANCH}} - - - uses: mad9000/actions-find-and-replace-string@2 - id: gh_branch_replace_slash - with: - source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }} - find: '/' - replace: '-' - - - uses: mad9000/actions-find-and-replace-string@2 - id: gh_branch_replace_dot - with: - source: ${{ steps.gh_branch_replace_slash.outputs.value }} - find: '.' - replace: '' - - - uses: mad9000/actions-find-and-replace-string@2 - id: gh_branch_clean - with: - source: ${{ steps.gh_branch_replace_dot.outputs.value }} - find: '_' - replace: '' - name: Uploading Proxy Source uses: actions/upload-artifact@v3 with: @@ -77,7 +45,6 @@ jobs: !./nginx !./deploy !./space - - name: Uploading Space Source uses: actions/upload-artifact@v3 with: @@ -89,12 +56,24 @@ jobs: !./deploy !./web outputs: - gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }} + gh_branch_name: ${{ env.TARGET_BRANCH }} branch_build_push_frontend: runs-on: ubuntu-20.04 - needs: [ branch_build_and_push ] + needs: [branch_build_setup] + env: + FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: + - name: Set Frontend Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable + else + TAG=${{ env.FRONTEND_TAG }} + fi + echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2.5.0 @@ -114,7 +93,7 @@ jobs: context: . file: ./web/Dockerfile.web platforms: linux/amd64 - tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + tags: ${{ env.FRONTEND_TAG }} push: true env: DOCKER_BUILDKIT: 1 @@ -123,8 +102,20 @@ jobs: branch_build_push_space: runs-on: ubuntu-20.04 - needs: [ branch_build_and_push ] + needs: [branch_build_setup] + env: + SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: + - name: Set Space Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable + else + TAG=${{ env.SPACE_TAG }} + fi + echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2.5.0 @@ -144,7 +135,7 @@ jobs: context: . file: ./space/Dockerfile.space platforms: linux/amd64 - tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + tags: ${{ env.SPACE_TAG }} push: true env: DOCKER_BUILDKIT: 1 @@ -153,8 +144,20 @@ jobs: branch_build_push_backend: runs-on: ubuntu-20.04 - needs: [ branch_build_and_push ] + needs: [branch_build_setup] + env: + BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: + - name: Set Backend Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable + else + TAG=${{ env.BACKEND_TAG }} + fi + echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2.5.0 @@ -175,7 +178,7 @@ jobs: file: ./Dockerfile.api platforms: linux/amd64 push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + tags: ${{ env.BACKEND_TAG }} env: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -183,8 +186,20 @@ jobs: branch_build_push_proxy: runs-on: ubuntu-20.04 - needs: [ branch_build_and_push ] + needs: [branch_build_setup] + env: + PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: + - name: Set Proxy Docker Tag + run: | + if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} + elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable + else + TAG=${{ env.PROXY_TAG }} + fi + echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2.5.0 @@ -205,7 +220,7 @@ jobs: context: . file: ./Dockerfile platforms: linux/amd64 - tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + tags: ${{ env.PROXY_TAG }} push: true env: DOCKER_BUILDKIT: 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..29fbde453 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,65 @@ +name: "CodeQL" + +on: + push: + branches: [ 'develop', 'hot-fix', 'stage-release' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ 'develop' ] + schedule: + - cron: '53 19 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python', 'javascript' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/update-docker-images.yml b/.github/workflows/update-docker-images.yml deleted file mode 100644 index 67ae97e75..000000000 --- a/.github/workflows/update-docker-images.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Update Docker Images for Plane on Release - -on: - release: - types: [released, prereleased] - -jobs: - build_push_backend: - name: Build and Push Api Server Docker Image - runs-on: ubuntu-20.04 - - steps: - - name: Check out the repo - uses: actions/checkout@v3.3.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 - - - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release - id: metaFrontend - uses: docker/metadata-action@v4.3.0 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend - tags: | - type=ref,event=tag - - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release - id: metaBackend - uses: docker/metadata-action@v4.3.0 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend - tags: | - type=ref,event=tag - - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release - id: metaSpace - uses: docker/metadata-action@v4.3.0 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space - tags: | - type=ref,event=tag - - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release - id: metaProxy - uses: docker/metadata-action@v4.3.0 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy - tags: | - type=ref,event=tag - - - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v4.0.0 - with: - context: . - file: ./web/Dockerfile.web - platforms: linux/amd64 - tags: ${{ steps.metaFrontend.outputs.tags }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Backend to Docker Hub - uses: docker/build-push-action@v4.0.0 - with: - context: ./apiserver - file: ./apiserver/Dockerfile.api - platforms: linux/amd64 - push: true - tags: ${{ steps.metaBackend.outputs.tags }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Plane-Deploy to Docker Hub - uses: docker/build-push-action@v4.0.0 - with: - context: . - file: ./space/Dockerfile.space - platforms: linux/amd64 - push: true - tags: ${{ steps.metaSpace.outputs.tags }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Plane-Proxy to Docker Hub - uses: docker/build-push-action@v4.0.0 - with: - context: ./nginx - file: ./nginx/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.metaProxy.outputs.tags }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index dcb8b8671..0b655bd0e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ pnpm-workspace.yaml tmp/ ## packages dist +.temp/ diff --git a/Dockerfile b/Dockerfile index 388c5a4ef..0f4ecfd36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,6 @@ FROM python:3.11.1-alpine3.17 AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV DJANGO_SETTINGS_MODULE plane.settings.production -ENV DOCKERIZED 1 WORKDIR /code @@ -81,7 +79,6 @@ COPY apiserver/manage.py manage.py COPY apiserver/plane plane/ COPY apiserver/templates templates/ -COPY apiserver/gunicorn.config.py ./ RUN apk --no-cache add "bash~=5.2" COPY apiserver/bin ./bin/ diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 23faf83f7..3e03244c6 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -31,12 +31,10 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ @@ -78,7 +76,6 @@ NEXT_PUBLIC_ENABLE_OAUTH=0 # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" ​ # Error logs SENTRY_DSN="" @@ -115,24 +112,22 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ +# Settings related to Docker +DOCKERIZED=1 # Deprecated + # Github GITHUB_CLIENT_SECRET="" # For fetching release notes ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ # Nginx Configuration NGINX_PORT=80 ​ -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" ​ # SignUps ENABLE_SIGNUP="1" diff --git a/README.md b/README.md index 53679943b..3f7404305 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,6 @@ Setting up local environment is extremely easy and straight forward. Follow the 1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system 1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d` -```bash -./setup.sh -``` - You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload) Thats it! diff --git a/apiserver/.env.example b/apiserver/.env.example index d3ad596e5..37178b398 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,10 +1,11 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.production" +CORS_ALLOWED_ORIGINS="" # Error logs SENTRY_DSN="" +SENTRY_ENVIRONMENT="development" # Database Settings PGUSER="plane" @@ -13,20 +14,16 @@ PGHOST="plane-db" PGDATABASE="plane" DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +# Oauth variables +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" - # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -38,29 +35,26 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Github GITHUB_CLIENT_SECRET="" # For fetching release notes # Settings related to Docker -DOCKERIZED=1 +DOCKERIZED=1 # deprecated + # set to 1 If using the pre-configured minio setup USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" - # Enable Email/Password Signup ENABLE_EMAIL_PASSWORD="1" @@ -70,6 +64,6 @@ ENABLE_MAGIC_LINK_LOGIN="0" # Email redirections and minio domain settings WEB_URL="http://localhost" - # Gunicorn Workers GUNICORN_WORKERS=2 + diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 15c3f53a9..0e4e0ac50 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -43,8 +43,7 @@ USER captain COPY manage.py manage.py COPY plane plane/ COPY templates templates/ - -COPY gunicorn.config.py ./ +COPY package.json package.json USER root RUN apk --no-cache add "bash~=5.2" COPY ./bin ./bin/ diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 9b09f244e..0ec2e495c 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,7 +3,28 @@ set -e python manage.py wait_for_db python manage.py migrate -# Create a Default User -python bin/user_script.py +# Create the default bucket +#!/bin/bash -exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py register_instance $MACHINE_SIGNATURE +# Load the configuration variable +python manage.py configure_instance + +# Create the default bucket +python manage.py create_bucket + +exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/user_script.py b/apiserver/bin/user_script.py deleted file mode 100644 index a356f2ec9..000000000 --- a/apiserver/bin/user_script.py +++ /dev/null @@ -1,28 +0,0 @@ -import os, sys -import uuid - -sys.path.append("/code") - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") -import django - -django.setup() - -from plane.db.models import User - - -def populate(): - default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so") - default_password = os.environ.get("DEFAULT_PASSWORD", "password123") - - if not User.objects.filter(email=default_email).exists(): - user = User.objects.create(email=default_email, username=uuid.uuid4().hex) - user.set_password(default_password) - user.save() - print(f"User created with an email: {default_email}") - else: - print(f"User already exists with the default email: {default_email}") - - -if __name__ == "__main__": - populate() diff --git a/apiserver/file.txt b/apiserver/file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/gunicorn.config.py b/apiserver/gunicorn.config.py deleted file mode 100644 index 51c2a5488..000000000 --- a/apiserver/gunicorn.config.py +++ /dev/null @@ -1,6 +0,0 @@ -from psycogreen.gevent import patch_psycopg - - -def post_fork(server, worker): - patch_psycopg() - worker.log.info("Made Psycopg2 Green") diff --git a/apiserver/package.json b/apiserver/package.json new file mode 100644 index 000000000..c622ae496 --- /dev/null +++ b/apiserver/package.json @@ -0,0 +1,4 @@ +{ + "name": "plane-api", + "version": "0.13.2" +} \ No newline at end of file diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py index 6ba36e7e5..292ad9344 100644 --- a/apiserver/plane/api/apps.py +++ b/apiserver/plane/api/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - name = "plane.api" + name = "plane.api" \ No newline at end of file diff --git a/apiserver/plane/api/middleware/__init__.py b/apiserver/plane/api/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/api/middleware/api_authentication.py b/apiserver/plane/api/middleware/api_authentication.py new file mode 100644 index 000000000..1b2c03318 --- /dev/null +++ b/apiserver/plane/api/middleware/api_authentication.py @@ -0,0 +1,47 @@ +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + +# Module imports +from plane.db.models import APIToken + + +class APIKeyAuthentication(authentication.BaseAuthentication): + """ + Authentication with an API Key + """ + + www_authenticate_realm = "api" + media_type = "application/json" + auth_header_name = "X-Api-Key" + + def get_api_token(self, request): + return request.headers.get(self.auth_header_name) + + def validate_api_token(self, token): + try: + api_token = APIToken.objects.get( + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + token=token, + is_active=True, + ) + except APIToken.DoesNotExist: + raise AuthenticationFailed("Given API token is not valid") + + # save api token last used + api_token.last_used = timezone.now() + api_token.save(update_fields=["last_used"]) + return (api_token.user, api_token.token) + + def authenticate(self, request): + token = self.get_api_token(request=request) + if not token: + return None + + # Validate the API token + user, token = self.validate_api_token(token) + return user, token \ No newline at end of file diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py deleted file mode 100644 index 8b15a9373..000000000 --- a/apiserver/plane/api/permissions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission -from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission diff --git a/apiserver/plane/api/rate_limit.py b/apiserver/plane/api/rate_limit.py new file mode 100644 index 000000000..f91e2d65d --- /dev/null +++ b/apiserver/plane/api/rate_limit.py @@ -0,0 +1,41 @@ +from rest_framework.throttling import SimpleRateThrottle + +class ApiKeyRateThrottle(SimpleRateThrottle): + scope = 'api_key' + rate = '60/minute' + + def get_cache_key(self, request, view): + # Retrieve the API key from the request header + api_key = request.headers.get('X-Api-Key') + if not api_key: + return None # Allow the request if there's no API key + + # Use the API key as part of the cache key + return f'{self.scope}:{api_key}' + + def allow_request(self, request, view): + allowed = super().allow_request(request, view) + + if allowed: + now = self.timer() + # Calculate the remaining limit and reset time + history = self.cache.get(self.key, []) + + # Remove old histories + while history and history[-1] <= now - self.duration: + history.pop() + + # Calculate the requests + num_requests = len(history) + + # Check available requests + available = self.num_requests - num_requests + + # Unix timestamp for when the rate limit will reset + reset_time = int(now + self.duration) + + # Add headers + request.META['X-RateLimit-Remaining'] = max(0, available) + request.META['X-RateLimit-Reset'] = reset_time + + return allowed \ No newline at end of file diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index f1a7de3b8..1fd1bce78 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -1,102 +1,17 @@ -from .base import BaseSerializer -from .user import ( - UserSerializer, - UserLiteSerializer, - ChangePasswordSerializer, - ResetPasswordSerializer, - UserAdminLiteSerializer, - UserMeSerializer, - UserMeSettingsSerializer, -) -from .workspace import ( - WorkSpaceSerializer, - WorkSpaceMemberSerializer, - TeamSerializer, - WorkSpaceMemberInviteSerializer, - WorkspaceLiteSerializer, - WorkspaceThemeSerializer, - WorkspaceMemberAdminSerializer, - WorkspaceMemberMeSerializer, -) -from .project import ( - ProjectSerializer, - ProjectListSerializer, - ProjectDetailSerializer, - ProjectMemberSerializer, - ProjectMemberInviteSerializer, - ProjectIdentifierSerializer, - ProjectFavoriteSerializer, - ProjectLiteSerializer, - ProjectMemberLiteSerializer, - ProjectDeployBoardSerializer, - ProjectMemberAdminSerializer, - ProjectPublicMemberSerializer, -) -from .state import StateSerializer, StateLiteSerializer -from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer -from .cycle import ( - CycleSerializer, - CycleIssueSerializer, - CycleFavoriteSerializer, - CycleWriteSerializer, -) -from .asset import FileAssetSerializer +from .user import UserLiteSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ProjectSerializer, ProjectLiteSerializer from .issue import ( - IssueCreateSerializer, - IssueActivitySerializer, - IssueCommentSerializer, - IssuePropertySerializer, - IssueAssigneeSerializer, - LabelSerializer, IssueSerializer, - IssueFlatSerializer, - IssueStateSerializer, + LabelSerializer, IssueLinkSerializer, - IssueLiteSerializer, IssueAttachmentSerializer, - IssueSubscriberSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssueRelationSerializer, - RelatedIssueSerializer, - IssuePublicSerializer, + IssueCommentSerializer, + IssueAttachmentSerializer, + IssueActivitySerializer, + IssueExpandSerializer, ) - -from .module import ( - ModuleWriteSerializer, - ModuleSerializer, - ModuleIssueSerializer, - ModuleLinkSerializer, - ModuleFavoriteSerializer, -) - -from .api_token import APITokenSerializer - -from .integration import ( - IntegrationSerializer, - WorkspaceIntegrationSerializer, - GithubIssueSyncSerializer, - GithubRepositorySerializer, - GithubRepositorySyncSerializer, - GithubCommentSyncSerializer, - SlackProjectSyncSerializer, -) - -from .importer import ImporterSerializer - -from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer - -from .estimate import ( - EstimateSerializer, - EstimatePointSerializer, - EstimateReadSerializer, -) - -from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer - -from .analytic import AnalyticViewSerializer - -from .notification import NotificationSerializer - -from .exporter import ExporterHistorySerializer +from .state import StateLiteSerializer, StateSerializer +from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer +from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer +from .inbox import InboxIssueSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/api_token.py b/apiserver/plane/api/serializers/api_token.py deleted file mode 100644 index 9c363f895..000000000 --- a/apiserver/plane/api/serializers/api_token.py +++ /dev/null @@ -1,14 +0,0 @@ -from .base import BaseSerializer -from plane.db.models import APIToken - - -class APITokenSerializer(BaseSerializer): - class Meta: - model = APIToken - fields = [ - "label", - "user", - "user_type", - "workspace", - "created_at", - ] diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index 89c9725d9..b96422501 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -1,22 +1,22 @@ +# Third party imports from rest_framework import serializers class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) -class DynamicBaseSerializer(BaseSerializer): - def __init__(self, *args, **kwargs): # If 'fields' is provided in the arguments, remove it and store it separately. # This is done so as not to pass this custom argument up to the superclass. - fields = kwargs.pop("fields", None) + fields = kwargs.pop("fields", []) + self.expand = kwargs.pop("expand", []) or [] # Call the initialization of the superclass. super().__init__(*args, **kwargs) # If 'fields' was provided, filter the fields of the serializer accordingly. - if fields is not None: - self.fields = self._filter_fields(fields) + if fields: + self.fields = self._filter_fields(fields=fields) def _filter_fields(self, fields): """ @@ -31,7 +31,7 @@ class DynamicBaseSerializer(BaseSerializer): # loop through its keys and values. if isinstance(field_name, dict): for key, value in field_name.items(): - # If the value of this nested field is a list, + # If the value of this nested field is a list, # perform a recursive filter on it. if isinstance(value, list): self._filter_fields(self.fields[key], value) @@ -52,7 +52,54 @@ class DynamicBaseSerializer(BaseSerializer): allowed = set(allowed) # Remove fields from the serializer that aren't in the 'allowed' list. - for field_name in (existing - allowed): + for field_name in existing - allowed: self.fields.pop(field_name) return self.fields + + def to_representation(self, instance): + response = super().to_representation(instance) + + # Ensure 'expand' is iterable before processing + if self.expand: + for expand in self.expand: + if expand in self.fields: + # Import all the expandable serializers + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + ) + + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + } + # Check if field in expansion then expand the field + if expand in expansion: + if isinstance(response.get(expand), list): + exp_serializer = expansion[expand]( + getattr(instance, expand), many=True + ) + else: + exp_serializer = expansion[expand]( + getattr(instance, expand) + ) + response[expand] = exp_serializer.data + else: + # You might need to handle this case differently + response[expand] = getattr(instance, f"{expand}_id", None) + + return response \ No newline at end of file diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 104a3dd06..eaff8181a 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -3,43 +3,19 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer -from .issue import IssueStateSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer -from plane.db.models import Cycle, CycleIssue, CycleFavorite - - -class CycleWriteSerializer(BaseSerializer): - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed end date") - return data - - class Meta: - model = Cycle - fields = "__all__" +from plane.db.models import Cycle, CycleIssue class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) - is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) completed_issues = serializers.IntegerField(read_only=True) started_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) - assignees = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - project_detail = ProjectLiteSerializer(read_only=True, source="project") def validate(self, data): if ( @@ -50,30 +26,15 @@ class CycleSerializer(BaseSerializer): raise serializers.ValidationError("Start date cannot exceed end date") return data - def get_assignees(self, obj): - members = [ - { - "avatar": assignee.avatar, - "display_name": assignee.display_name, - "id": assignee.id, - } - for issue_cycle in obj.issue_cycle.prefetch_related( - "issue__assignees" - ).all() - for assignee in issue_cycle.issue.assignees.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in members} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list - class Meta: model = Cycle fields = "__all__" read_only_fields = [ + "id", + "created_at", + "updated_at", + "created_by", + "updated_by", "workspace", "project", "owned_by", @@ -81,7 +42,6 @@ class CycleSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer): - issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) class Meta: @@ -94,14 +54,8 @@ class CycleIssueSerializer(BaseSerializer): ] -class CycleFavoriteSerializer(BaseSerializer): - cycle_detail = CycleSerializer(source="cycle", read_only=True) +class CycleLiteSerializer(BaseSerializer): class Meta: - model = CycleFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "user", - ] + model = Cycle + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index f52a90660..17ae8c1ed 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -1,57 +1,19 @@ -# Third party frameworks -from rest_framework import serializers - -# Module imports +# Module improts from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelLiteSerializer -from .project import ProjectLiteSerializer -from .state import StateLiteSerializer -from .user import UserLiteSerializer -from plane.db.models import Inbox, InboxIssue, Issue - - -class InboxSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(source="project", read_only=True) - pending_issue_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Inbox - fields = "__all__" - read_only_fields = [ - "project", - "workspace", - ] - +from plane.db.models import InboxIssue class InboxIssueSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: model = InboxIssue fields = "__all__" read_only_fields = [ - "project", + "id", "workspace", - ] - - -class InboxIssueLiteSerializer(BaseSerializer): - class Meta: - model = InboxIssue - fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] - read_only_fields = fields - - -class IssueStateInboxSerializer(BaseSerializer): - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - bridge_id = serializers.UUIDField(read_only=True) - issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) - - class Meta: - model = Issue - fields = "__all__" + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] \ No newline at end of file diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index ae033969f..ab61ae523 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,95 +1,53 @@ +from lxml import html + + # Django imports from django.utils import timezone -# Third Party imports +# Third party imports from rest_framework import serializers # Module imports -from .base import BaseSerializer, DynamicBaseSerializer -from .user import UserLiteSerializer -from .state import StateSerializer, StateLiteSerializer -from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, Issue, - IssueActivity, - IssueComment, - IssueProperty, + State, IssueAssignee, - IssueSubscriber, - IssueLabel, Label, - CycleIssue, - Cycle, - Module, - ModuleIssue, + IssueLabel, IssueLink, + IssueComment, IssueAttachment, - IssueReaction, - CommentReaction, - IssueVote, - IssueRelation, + IssueActivity, + ProjectMember, ) +from .base import BaseSerializer +from .cycle import CycleSerializer, CycleLiteSerializer +from .module import ModuleSerializer, ModuleLiteSerializer +from .user import UserLiteSerializer +from .state import StateLiteSerializer - -class IssueFlatSerializer(BaseSerializer): - ## Contain only flat fields - - class Meta: - model = Issue - fields = [ - "id", - "name", - "description", - "description_html", - "priority", - "start_date", - "target_date", - "sequence_id", - "sort_order", - "is_draft", - ] - - -class IssueProjectLiteSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(source="project", read_only=True) - - class Meta: - model = Issue - fields = [ - "id", - "project_detail", - "name", - "sequence_id", - ] - read_only_fields = fields - - -##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 = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - +class IssueSerializer(BaseSerializer): assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + child=serializers.PrimaryKeyRelatedField( + queryset=User.objects.values_list("id", flat=True) + ), write_only=True, required=False, ) labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + child=serializers.PrimaryKeyRelatedField( + queryset=Label.objects.values_list("id", flat=True) + ), write_only=True, required=False, ) class Meta: model = Issue - fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "created_by", @@ -97,12 +55,10 @@ class IssueCreateSerializer(BaseSerializer): "created_at", "updated_at", ] - - def to_representation(self, instance): - data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] - return data + exclude = [ + "description", + "description_stripped", + ] def validate(self, data): if ( @@ -111,6 +67,53 @@ class IssueCreateSerializer(BaseSerializer): and data.get("start_date", None) > data.get("target_date", None) ): raise serializers.ValidationError("Start date cannot exceed target date") + + try: + if(data.get("description_html", None) is not None): + parsed = html.fromstring(data["description_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["description_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + + # Validate assignees are from project + if data.get("assignees", []): + data["assignees"] = ProjectMember.objects.filter( + project_id=self.context.get("project_id"), + is_active=True, + member_id__in=data["assignees"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if data.get("labels", []): + data["labels"] = Label.objects.filter( + project_id=self.context.get("project_id"), + id__in=data["labels"], + ).values_list("id", flat=True) + + # Check state is from the project only else raise validation error + if ( + data.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), pk=data.get("state") + ).exists() + ): + raise serializers.ValidationError( + "State is not valid please pass a valid state_id" + ) + + # Check parent issue is from workspace as it can be cross workspace + if ( + data.get("parent") + and not Issue.objects.filter( + workspace_id=self.context.get("workspace_id"), pk=data.get("parent") + ).exists() + ): + raise serializers.ValidationError( + "Parent is not valid issue_id please pass a valid issue_id" + ) + return data def create(self, validated_data): @@ -131,14 +134,14 @@ class IssueCreateSerializer(BaseSerializer): IssueAssignee.objects.bulk_create( [ IssueAssignee( - assignee=user, + assignee_id=assignee_id, issue=issue, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for user in assignees + for assignee_id in assignees ], batch_size=10, ) @@ -158,14 +161,14 @@ class IssueCreateSerializer(BaseSerializer): IssueLabel.objects.bulk_create( [ IssueLabel( - label=label, + label_id=label_id, issue=issue, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for label in labels + for label_id in labels ], batch_size=10, ) @@ -187,14 +190,14 @@ class IssueCreateSerializer(BaseSerializer): IssueAssignee.objects.bulk_create( [ IssueAssignee( - assignee=user, + assignee_id=assignee_id, issue=instance, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for user in assignees + for assignee_id in assignees ], batch_size=10, ) @@ -204,14 +207,14 @@ class IssueCreateSerializer(BaseSerializer): IssueLabel.objects.bulk_create( [ IssueLabel( - label=label, + label_id=label_id, issue=instance, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for label in labels + for label_id in labels ], batch_size=10, ) @@ -220,157 +223,34 @@ class IssueCreateSerializer(BaseSerializer): instance.updated_at = timezone.now() return super().update(instance, validated_data) + def to_representation(self, instance): + data = super().to_representation(instance) + if "assignees" in self.fields: + if "assignees" in self.expand: + from .user import UserLiteSerializer -class IssueActivitySerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") + data["assignees"] = UserLiteSerializer( + instance.assignees.all(), many=True + ).data + else: + data["assignees"] = [ + str(assignee.id) for assignee in instance.assignees.all() + ] + if "labels" in self.fields: + if "labels" in self.expand: + data["labels"] = LabelSerializer(instance.labels.all(), many=True).data + else: + data["labels"] = [str(label.id) for label in instance.labels.all()] - class Meta: - model = IssueActivity - fields = "__all__" - - - -class IssuePropertySerializer(BaseSerializer): - class Meta: - model = IssueProperty - fields = "__all__" - read_only_fields = [ - "user", - "workspace", - "project", - ] + return data class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - class Meta: model = Label fields = "__all__" read_only_fields = [ - "workspace", - "project", - ] - - -class LabelLiteSerializer(BaseSerializer): - class Meta: - model = Label - fields = [ "id", - "name", - "color", - ] - - -class IssueLabelSerializer(BaseSerializer): - - class Meta: - model = IssueLabel - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] - - -class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") - - class Meta: - model = IssueRelation - fields = [ - "issue_detail", - "relation_type", - "related_issue", - "issue", - "id" - ] - read_only_fields = [ - "workspace", - "project", - ] - -class RelatedIssueSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") - - class Meta: - model = IssueRelation - fields = [ - "issue_detail", - "relation_type", - "related_issue", - "issue", - "id" - ] - read_only_fields = [ - "workspace", - "project", - ] - - -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", @@ -381,19 +261,18 @@ class IssueModuleDetailSerializer(BaseSerializer): class IssueLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - class Meta: model = IssueLink fields = "__all__" read_only_fields = [ + "id", "workspace", "project", + "issue", "created_by", "updated_by", "created_at", "updated_at", - "issue", ] # Validation if url already exists @@ -412,73 +291,24 @@ class IssueAttachmentSerializer(BaseSerializer): model = IssueAttachment fields = "__all__" read_only_fields = [ + "id", + "workspace", + "project", + "issue", "created_by", "updated_by", "created_at", "updated_at", - "workspace", - "project", - "issue", ] -class IssueReactionSerializer(BaseSerializer): - - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = IssueReaction - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "actor", - ] - - -class CommentReactionLiteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = CommentReaction - fields = [ - "id", - "reaction", - "comment", - "actor_detail", - ] - - -class CommentReactionSerializer(BaseSerializer): - class Meta: - model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor"] - - -class IssueVoteSerializer(BaseSerializer): - - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] - read_only_fields = fields - - class IssueCommentSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) class Meta: model = IssueComment - fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "issue", @@ -487,58 +317,73 @@ class IssueCommentSerializer(BaseSerializer): "created_at", "updated_at", ] + exclude = [ + "comment_stripped", + "comment_json", + ] + + def validate(self, data): + try: + if(data.get("comment_html", None) is not None): + parsed = html.fromstring(data["comment_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["comment_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + return data -class IssueStateFlatSerializer(BaseSerializer): - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - +class IssueActivitySerializer(BaseSerializer): class Meta: - model = Issue - fields = [ - "id", - "sequence_id", - "name", - "state_detail", - "project_detail", + model = IssueActivity + exclude = [ + "created_by", + "updated_by", ] -# Issue Serializer with state details -class IssueStateSerializer(BaseSerializer): - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - bridge_id = serializers.UUIDField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) +class CycleIssueSerializer(BaseSerializer): + cycle = CycleSerializer(read_only=True) class Meta: - model = Issue - fields = "__all__" + fields = [ + "cycle", + ] -class IssueSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateSerializer(read_only=True, source="state") - parent_detail = IssueStateFlatSerializer(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) - related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) - issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) - issue_cycle = IssueCycleDetailSerializer(read_only=True) - issue_module = IssueModuleDetailSerializer(read_only=True) - issue_link = IssueLinkSerializer(read_only=True, many=True) - issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) +class ModuleIssueSerializer(BaseSerializer): + module = ModuleSerializer(read_only=True) + + class Meta: + fields = [ + "module", + ] + + +class LabelLiteSerializer(BaseSerializer): + + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + +class IssueExpandSerializer(BaseSerializer): + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) + module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + labels = LabelLiteSerializer(read_only=True, many=True) + assignees = UserLiteSerializer(read_only=True, many=True) + state = StateLiteSerializer(read_only=True) class Meta: model = Issue fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "created_by", @@ -546,70 +391,3 @@ class IssueSerializer(BaseSerializer): "created_at", "updated_at", ] - - -class IssueLiteSerializer(DynamicBaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - cycle_id = serializers.UUIDField(read_only=True) - module_id = serializers.UUIDField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) - - class Meta: - model = Issue - fields = "__all__" - read_only_fields = [ - "start_date", - "target_date", - "completed_at", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssuePublicSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") - votes = IssueVoteSerializer(read_only=True, many=True) - - class Meta: - model = Issue - fields = [ - "id", - "name", - "description_html", - "sequence_id", - "state", - "state_detail", - "project", - "project_detail", - "workspace", - "priority", - "target_date", - "reactions", - "votes", - ] - read_only_fields = fields - - - -class IssueSubscriberSerializer(BaseSerializer): - class Meta: - model = IssueSubscriber - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - ] diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index 48f773b0f..65710e8af 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -1,36 +1,38 @@ -# Third Party imports +# Third party imports from rest_framework import serializers # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer -from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer - from plane.db.models import ( User, Module, + ModuleLink, ModuleMember, ModuleIssue, - ModuleLink, - ModuleFavorite, + ProjectMember, ) -class ModuleWriteSerializer(BaseSerializer): +class ModuleSerializer(BaseSerializer): members = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + child=serializers.PrimaryKeyRelatedField( + queryset=User.objects.values_list("id", flat=True) + ), write_only=True, required=False, ) - - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) class Meta: model = Module fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "created_by", @@ -38,16 +40,27 @@ class ModuleWriteSerializer(BaseSerializer): "created_at", "updated_at", ] - + def to_representation(self, instance): data = super().to_representation(instance) - data['members'] = [str(member.id) for member in instance.members.all()] + data["members"] = [str(member.id) for member in instance.members.all()] return data def validate(self, data): - if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): raise serializers.ValidationError("Start date cannot exceed target date") - return data + + if data.get("members", []): + data["members"] = ProjectMember.objects.filter( + project_id=self.context.get("project_id"), + member_id__in=data["members"], + ).values_list("member_id", flat=True) + + return data def create(self, validated_data): members = validated_data.pop("members", None) @@ -99,23 +112,7 @@ class ModuleWriteSerializer(BaseSerializer): 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 = ProjectLiteSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) class Meta: @@ -133,8 +130,6 @@ class ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - class Meta: model = ModuleLink fields = "__all__" @@ -157,42 +152,10 @@ class ModuleLinkSerializer(BaseSerializer): {"error": "URL already exists for this Issue"} ) return ModuleLink.objects.create(**validated_data) + - -class ModuleSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - lead_detail = UserLiteSerializer(read_only=True, source="lead") - members_detail = UserLiteSerializer(read_only=True, many=True, source="members") - link_module = ModuleLinkSerializer(read_only=True, many=True) - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - cancelled_issues = serializers.IntegerField(read_only=True) - completed_issues = serializers.IntegerField(read_only=True) - started_issues = serializers.IntegerField(read_only=True) - unstarted_issues = serializers.IntegerField(read_only=True) - backlog_issues = serializers.IntegerField(read_only=True) +class ModuleLiteSerializer(BaseSerializer): class Meta: model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class ModuleFavoriteSerializer(BaseSerializer): - module_detail = ModuleFlatSerializer(source="module", read_only=True) - - class Meta: - model = ModuleFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "user", - ] + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 36fa6ecca..c394a080d 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -2,30 +2,60 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer, DynamicBaseSerializer -from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer -from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer -from plane.db.models import ( - Project, - ProjectMember, - ProjectMemberInvite, - ProjectIdentifier, - ProjectFavorite, - ProjectDeployBoard, - ProjectPublicMember, -) +from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate +from .base import BaseSerializer class ProjectSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + total_members = serializers.IntegerField(read_only=True) + total_cycles = serializers.IntegerField(read_only=True) + total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + is_deployed = serializers.BooleanField(read_only=True) class Meta: model = Project fields = "__all__" read_only_fields = [ + "id", + 'emoji', "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", ] + def validate(self, data): + # Check project lead should be a member of the workspace + if ( + data.get("project_lead", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("project_lead"), + ).exists() + ): + raise serializers.ValidationError( + "Project lead should be a user in the workspace" + ) + + # Check default assignee should be a member of the workspace + if ( + data.get("default_assignee", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("default_assignee"), + ).exists() + ): + raise serializers.ValidationError( + "Default assignee should be a user in the workspace" + ) + + return data + def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": @@ -35,6 +65,7 @@ class ProjectSerializer(BaseSerializer): 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"] ) @@ -45,36 +76,6 @@ class ProjectSerializer(BaseSerializer): ) 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) - project_identifier = ProjectIdentifier.objects.filter( - project=project - ).first() - if project_identifier is not None: - project_identifier.name = identifier - project_identifier.save() - 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 ProjectLiteSerializer(BaseSerializer): class Meta: @@ -88,127 +89,4 @@ class ProjectLiteSerializer(BaseSerializer): "emoji", "description", ] - read_only_fields = fields - - -class ProjectListSerializer(DynamicBaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) - is_member = serializers.BooleanField(read_only=True) - sort_order = serializers.FloatField(read_only=True) - member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) - members = serializers.SerializerMethodField() - - def get_members(self, obj): - project_members = ProjectMember.objects.filter(project_id=obj.id).values( - "id", - "member_id", - "member__display_name", - "member__avatar", - ) - return project_members - - class Meta: - model = Project - fields = "__all__" - - -class ProjectDetailSerializer(BaseSerializer): - # workspace = WorkSpaceSerializer(read_only=True) - default_assignee = UserLiteSerializer(read_only=True) - project_lead = UserLiteSerializer(read_only=True) - is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) - is_member = serializers.BooleanField(read_only=True) - sort_order = serializers.FloatField(read_only=True) - member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) - - class Meta: - model = Project - fields = "__all__" - - -class ProjectMemberSerializer(BaseSerializer): - workspace = WorkspaceLiteSerializer(read_only=True) - project = ProjectLiteSerializer(read_only=True) - member = UserLiteSerializer(read_only=True) - - class Meta: - model = ProjectMember - fields = "__all__" - - -class ProjectMemberAdminSerializer(BaseSerializer): - workspace = WorkspaceLiteSerializer(read_only=True) - project = ProjectLiteSerializer(read_only=True) - member = UserAdminLiteSerializer(read_only=True) - - class Meta: - model = ProjectMember - fields = "__all__" - - -class ProjectMemberInviteSerializer(BaseSerializer): - project = ProjectLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) - - class Meta: - model = ProjectMemberInvite - fields = "__all__" - - -class ProjectIdentifierSerializer(BaseSerializer): - class Meta: - model = ProjectIdentifier - fields = "__all__" - - -class ProjectFavoriteSerializer(BaseSerializer): - class Meta: - model = ProjectFavorite - fields = "__all__" - read_only_fields = [ - "workspace", - "user", - ] - - -class ProjectMemberLiteSerializer(BaseSerializer): - member = UserLiteSerializer(read_only=True) - is_subscribed = serializers.BooleanField(read_only=True) - - class Meta: - model = ProjectMember - fields = ["member", "id", "is_subscribed"] - read_only_fields = fields - - -class ProjectDeployBoardSerializer(BaseSerializer): - project_details = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - class Meta: - model = ProjectDeployBoard - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "anchor", - ] - - -class ProjectPublicMemberSerializer(BaseSerializer): - class Meta: - model = ProjectPublicMember - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "member", - ] + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index ad416c340..9d08193d8 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -1,17 +1,26 @@ # Module imports from .base import BaseSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer - from plane.db.models import State class StateSerializer(BaseSerializer): + def validate(self, data): + # If the default is being provided then make all other states default False + if data.get("default", False): + State.objects.filter(project_id=self.context.get("project_id")).update( + default=False + ) + return data class Meta: model = State fields = "__all__" read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", "workspace", "project", ] @@ -26,4 +35,4 @@ class StateLiteSerializer(BaseSerializer): "color", "group", ] - read_only_fields = fields + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index b8f9dedd4..42b6c3967 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -1,111 +1,6 @@ -# Third party imports -from rest_framework import serializers - -# Module import +# Module imports +from plane.db.models import User from .base import BaseSerializer -from plane.db.models import User, Workspace, WorkspaceMemberInvite - - -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", - "is_bot", - ] - extra_kwargs = {"password": {"write_only": True}} - - # If the user has already filled first name or last name then he is onboarded - def get_is_onboarded(self, obj): - return bool(obj.first_name) or bool(obj.last_name) - - -class UserMeSerializer(BaseSerializer): - class Meta: - model = User - fields = [ - "id", - "avatar", - "cover_image", - "date_joined", - "display_name", - "email", - "first_name", - "last_name", - "is_active", - "is_bot", - "is_email_verified", - "is_managed", - "is_onboarded", - "is_tour_completed", - "mobile_number", - "role", - "onboarding_step", - "user_timezone", - "username", - "theme", - "last_workspace_id", - ] - read_only_fields = fields - - -class UserMeSettingsSerializer(BaseSerializer): - workspace = serializers.SerializerMethodField() - - class Meta: - model = User - fields = [ - "id", - "email", - "workspace", - ] - read_only_fields = fields - - def get_workspace(self, obj): - workspace_invites = WorkspaceMemberInvite.objects.filter( - email=obj.email - ).count() - if obj.last_workspace_id is not None: - workspace = Workspace.objects.filter( - pk=obj.last_workspace_id, workspace_member__member=obj.id - ).first() - return { - "last_workspace_id": obj.last_workspace_id, - "last_workspace_slug": workspace.slug if workspace is not None else "", - "fallback_workspace_id": obj.last_workspace_id, - "fallback_workspace_slug": workspace.slug if workspace is not None else "", - "invites": workspace_invites, - } - else: - fallback_workspace = ( - Workspace.objects.filter(workspace_member__member_id=obj.id) - .order_by("created_at") - .first() - ) - return { - "last_workspace_id": None, - "last_workspace_slug": None, - "fallback_workspace_id": fallback_workspace.id - if fallback_workspace is not None - else None, - "fallback_workspace_slug": fallback_workspace.slug - if fallback_workspace is not None - else None, - "invites": workspace_invites, - } class UserLiteSerializer(BaseSerializer): @@ -116,48 +11,6 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", - "is_bot", "display_name", ] - read_only_fields = [ - "id", - "is_bot", - ] - - -class UserAdminLiteSerializer(BaseSerializer): - class Meta: - model = User - fields = [ - "id", - "first_name", - "last_name", - "avatar", - "is_bot", - "display_name", - "email", - ] - read_only_fields = [ - "id", - "is_bot", - ] - - -class ChangePasswordSerializer(serializers.Serializer): - model = User - - """ - Serializer for password change endpoint. - """ - old_password = serializers.CharField(required=True) - new_password = serializers.CharField(required=True) - - -class ResetPasswordSerializer(serializers.Serializer): - model = User - - """ - Serializer for password change endpoint. - """ - new_password = serializers.CharField(required=True) - confirm_password = serializers.CharField(required=True) + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index 0a80ce8b7..c4c5caceb 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -1,39 +1,10 @@ -# Third party imports -from rest_framework import serializers - # Module imports +from plane.db.models import Workspace from .base import BaseSerializer -from .user import UserLiteSerializer, UserAdminLiteSerializer -from plane.db.models import ( - User, - Workspace, - WorkspaceMember, - Team, - TeamMember, - WorkspaceMemberInvite, - WorkspaceTheme, -) - - -class WorkSpaceSerializer(BaseSerializer): - owner = UserLiteSerializer(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - - class Meta: - model = Workspace - fields = "__all__" - read_only_fields = [ - "id", - "created_by", - "updated_by", - "created_at", - "updated_at", - "owner", - ] class WorkspaceLiteSerializer(BaseSerializer): + """Lite serializer with only required fields""" class Meta: model = Workspace fields = [ @@ -41,96 +12,4 @@ class WorkspaceLiteSerializer(BaseSerializer): "slug", "id", ] - read_only_fields = fields - - - -class WorkSpaceMemberSerializer(BaseSerializer): - member = UserLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) - - class Meta: - model = WorkspaceMember - fields = "__all__" - - -class WorkspaceMemberMeSerializer(BaseSerializer): - - class Meta: - model = WorkspaceMember - fields = "__all__" - - -class WorkspaceMemberAdminSerializer(BaseSerializer): - member = UserAdminLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) - - class Meta: - model = WorkspaceMember - fields = "__all__" - - -class WorkSpaceMemberInviteSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) - total_members = serializers.IntegerField(read_only=True) - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - - 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 - 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) - return super().update(instance, validated_data) - - -class WorkspaceThemeSerializer(BaseSerializer): - class Meta: - model = WorkspaceTheme - fields = "__all__" - read_only_fields = [ - "workspace", - "actor", - ] + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 957dac24e..a5ef0f5f1 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -1,46 +1,15 @@ -from .analytic import urlpatterns as analytic_urls -from .asset import urlpatterns as asset_urls -from .authentication import urlpatterns as authentication_urls -from .config import urlpatterns as configuration_urls -from .cycle import urlpatterns as cycle_urls -from .estimate import urlpatterns as estimate_urls -from .external import urlpatterns as external_urls -from .importer import urlpatterns as importer_urls -from .inbox import urlpatterns as inbox_urls -from .integration import urlpatterns as integration_urls -from .issue import urlpatterns as issue_urls -from .module import urlpatterns as module_urls -from .notification import urlpatterns as notification_urls -from .page import urlpatterns as page_urls -from .project import urlpatterns as project_urls -from .public_board import urlpatterns as public_board_urls -from .search import urlpatterns as search_urls -from .state import urlpatterns as state_urls -from .user import urlpatterns as user_urls -from .views import urlpatterns as view_urls -from .workspace import urlpatterns as workspace_urls - +from .project import urlpatterns as project_patterns +from .state import urlpatterns as state_patterns +from .issue import urlpatterns as issue_patterns +from .cycle import urlpatterns as cycle_patterns +from .module import urlpatterns as module_patterns +from .inbox import urlpatterns as inbox_patterns urlpatterns = [ - *analytic_urls, - *asset_urls, - *authentication_urls, - *configuration_urls, - *cycle_urls, - *estimate_urls, - *external_urls, - *importer_urls, - *inbox_urls, - *integration_urls, - *issue_urls, - *module_urls, - *notification_urls, - *page_urls, - *project_urls, - *public_board_urls, - *search_urls, - *state_urls, - *user_urls, - *view_urls, - *workspace_urls, -] + *project_patterns, + *state_patterns, + *issue_patterns, + *cycle_patterns, + *module_patterns, + *inbox_patterns, +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index 068276361..f557f8af0 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -1,87 +1,35 @@ from django.urls import path - -from plane.api.views import ( - CycleViewSet, - CycleIssueViewSet, - CycleDateCheckEndpoint, - CycleFavoriteViewSet, - TransferCycleIssueEndpoint, +from plane.api.views.cycle import ( + CycleAPIEndpoint, + CycleIssueAPIEndpoint, + TransferCycleIssueAPIEndpoint, ) - urlpatterns = [ path( "workspaces//projects//cycles/", - CycleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-cycle", + CycleAPIEndpoint.as_view(), + name="cycles", ), path( "workspaces//projects//cycles//", - CycleViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-cycle", + CycleAPIEndpoint.as_view(), + name="cycles", ), path( "workspaces//projects//cycles//cycle-issues/", - CycleIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-cycle", + CycleIssueAPIEndpoint.as_view(), + name="cycle-issues", ), path( - "workspaces//projects//cycles//cycle-issues//", - CycleIssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-cycle", - ), - path( - "workspaces//projects//cycles/date-check/", - CycleDateCheckEndpoint.as_view(), - name="project-cycle-date", - ), - path( - "workspaces//projects//user-favorite-cycles/", - CycleFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="user-favorite-cycle", - ), - path( - "workspaces//projects//user-favorite-cycles//", - CycleFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), - name="user-favorite-cycle", + "workspaces//projects//cycles//cycle-issues//", + CycleIssueAPIEndpoint.as_view(), + name="cycle-issues", ), path( "workspaces//projects//cycles//transfer-issues/", - TransferCycleIssueEndpoint.as_view(), + TransferCycleIssueAPIEndpoint.as_view(), name="transfer-issues", ), -] +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/inbox.py b/apiserver/plane/api/urls/inbox.py index 315f30601..3a2a57786 100644 --- a/apiserver/plane/api/urls/inbox.py +++ b/apiserver/plane/api/urls/inbox.py @@ -1,53 +1,17 @@ from django.urls import path - -from plane.api.views import ( - InboxViewSet, - InboxIssueViewSet, -) +from plane.api.views import InboxIssueAPIEndpoint urlpatterns = [ path( - "workspaces//projects//inboxes/", - InboxViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inboxes//", - InboxViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inboxes//inbox-issues/", - InboxIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + "workspaces//projects//inbox-issues/", + InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", - InboxIssueViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), + "workspaces//projects//inbox-issues//", + InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), -] +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 23a8e4fa6..070ea8bd9 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -1,327 +1,62 @@ from django.urls import path - from plane.api.views import ( - IssueViewSet, - IssueListEndpoint, - IssueListGroupedEndpoint, - LabelViewSet, - BulkCreateIssueLabelsEndpoint, - BulkDeleteIssuesEndpoint, - BulkImportIssuesEndpoint, - UserWorkSpaceIssues, - SubIssuesEndpoint, - IssueLinkViewSet, - IssueAttachmentEndpoint, - ExportIssuesEndpoint, - IssueActivityEndpoint, - IssueCommentViewSet, - IssueSubscriberViewSet, - IssueReactionViewSet, - CommentReactionViewSet, - IssueUserDisplayPropertyEndpoint, - IssueArchiveViewSet, - IssueRelationViewSet, - IssueDraftViewSet, + IssueAPIEndpoint, + LabelAPIEndpoint, + IssueLinkAPIEndpoint, + IssueCommentAPIEndpoint, + IssueActivityAPIEndpoint, ) - urlpatterns = [ path( "workspaces//projects//issues/", - IssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue", - ), - path( - "v2/workspaces//projects//issues/", - IssueListEndpoint.as_view(), - name="project-issue", - ), - path( - "v3/workspaces//projects//issues/", - IssueListGroupedEndpoint.as_view(), - name="project-issue", + IssueAPIEndpoint.as_view(), + name="issue", ), path( "workspaces//projects//issues//", - IssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue", + IssueAPIEndpoint.as_view(), + name="issue", ), path( - "workspaces//projects//issue-labels/", - LabelViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-labels", + "workspaces//projects//labels/", + LabelAPIEndpoint.as_view(), + name="label", ), path( - "workspaces//projects//issue-labels//", - LabelViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-labels", + "workspaces//projects//labels//", + LabelAPIEndpoint.as_view(), + name="label", ), path( - "workspaces//projects//bulk-create-labels/", - BulkCreateIssueLabelsEndpoint.as_view(), - name="project-bulk-labels", + "workspaces//projects//issues//links/", + IssueLinkAPIEndpoint.as_view(), + name="link", ), path( - "workspaces//projects//bulk-delete-issues/", - BulkDeleteIssuesEndpoint.as_view(), - name="project-issues-bulk", + "workspaces//projects//issues//links//", + IssueLinkAPIEndpoint.as_view(), + name="link", ), - path( - "workspaces//projects//bulk-import-issues//", - BulkImportIssuesEndpoint.as_view(), - name="project-issues-bulk", - ), - path( - "workspaces//my-issues/", - UserWorkSpaceIssues.as_view(), - name="workspace-issues", - ), - path( - "workspaces//projects//issues//sub-issues/", - SubIssuesEndpoint.as_view(), - name="sub-issues", - ), - path( - "workspaces//projects//issues//issue-links/", - IssueLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-links", - ), - path( - "workspaces//projects//issues//issue-links//", - IssueLinkViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-links", - ), - path( - "workspaces//projects//issues//issue-attachments/", - IssueAttachmentEndpoint.as_view(), - name="project-issue-attachments", - ), - path( - "workspaces//projects//issues//issue-attachments//", - IssueAttachmentEndpoint.as_view(), - name="project-issue-attachments", - ), - path( - "workspaces//export-issues/", - ExportIssuesEndpoint.as_view(), - name="export-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", + IssueCommentAPIEndpoint.as_view(), + name="comment", ), path( "workspaces//projects//issues//comments//", - IssueCommentViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-comment", - ), - ## End IssueComments - # Issue Subscribers - path( - "workspaces//projects//issues//issue-subscribers/", - IssueSubscriberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-subscribers", + IssueCommentAPIEndpoint.as_view(), + name="comment", ), path( - "workspaces//projects//issues//issue-subscribers//", - IssueSubscriberViewSet.as_view({"delete": "destroy"}), - name="project-issue-subscribers", + "workspaces//projects//issues//activities/", + IssueActivityAPIEndpoint.as_view(), + name="activity", ), path( - "workspaces//projects//issues//subscribe/", - IssueSubscriberViewSet.as_view( - { - "get": "subscription_status", - "post": "subscribe", - "delete": "unsubscribe", - } - ), - name="project-issue-subscribers", - ), - ## End Issue Subscribers - # Issue Reactions - path( - "workspaces//projects//issues//reactions/", - IssueReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-reactions", - ), - path( - "workspaces//projects//issues//reactions//", - IssueReactionViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project-issue-reactions", - ), - ## End Issue Reactions - # Comment Reactions - path( - "workspaces//projects//comments//reactions/", - CommentReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-comment-reactions", - ), - path( - "workspaces//projects//comments//reactions//", - CommentReactionViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project-issue-comment-reactions", - ), - ## End Comment Reactions - ## IssueProperty - path( - "workspaces//projects//issue-display-properties/", - IssueUserDisplayPropertyEndpoint.as_view(), - name="project-issue-display-properties", - ), - ## IssueProperty End - ## Issue Archives - path( - "workspaces//projects//archived-issues/", - IssueArchiveViewSet.as_view( - { - "get": "list", - } - ), - name="project-issue-archive", - ), - path( - "workspaces//projects//archived-issues//", - IssueArchiveViewSet.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", - ), - ## End Issue Archives - ## Issue Relation - path( - "workspaces//projects//issues//issue-relation/", - IssueRelationViewSet.as_view( - { - "post": "create", - } - ), - name="issue-relation", - ), - path( - "workspaces//projects//issues//issue-relation//", - IssueRelationViewSet.as_view( - { - "delete": "destroy", - } - ), - name="issue-relation", - ), - ## End Issue Relation - ## Issue Drafts - path( - "workspaces//projects//issue-drafts/", - IssueDraftViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-draft", - ), - path( - "workspaces//projects//issue-drafts//", - IssueDraftViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-draft", + "workspaces//projects//issues//activities//", + IssueActivityAPIEndpoint.as_view(), + name="activity", ), ] diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 3239af1e4..7117a9e8b 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -1,104 +1,26 @@ from django.urls import path - -from plane.api.views import ( - ModuleViewSet, - ModuleIssueViewSet, - ModuleLinkViewSet, - ModuleFavoriteViewSet, - BulkImportModulesEndpoint, -) - +from plane.api.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint urlpatterns = [ path( "workspaces//projects//modules/", - ModuleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-modules", + ModuleAPIEndpoint.as_view(), + name="modules", ), path( "workspaces//projects//modules//", - ModuleViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-modules", + ModuleAPIEndpoint.as_view(), + name="modules", ), path( "workspaces//projects//modules//module-issues/", - ModuleIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-module-issues", + ModuleIssueAPIEndpoint.as_view(), + name="module-issues", ), path( - "workspaces//projects//modules//module-issues//", - ModuleIssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-module-issues", + "workspaces//projects//modules//module-issues//", + ModuleIssueAPIEndpoint.as_view(), + name="module-issues", ), - path( - "workspaces//projects//modules//module-links/", - ModuleLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-module-links", - ), - path( - "workspaces//projects//modules//module-links//", - ModuleLinkViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-module-links", - ), - path( - "workspaces//projects//user-favorite-modules/", - ModuleFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="user-favorite-module", - ), - path( - "workspaces//projects//user-favorite-modules//", - ModuleFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), - name="user-favorite-module", - ), - path( - "workspaces//projects//bulk-import-modules//", - BulkImportModulesEndpoint.as_view(), - name="bulk-modules-create", - ), -] +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/page.py b/apiserver/plane/api/urls/page.py deleted file mode 100644 index 648702283..000000000 --- a/apiserver/plane/api/urls/page.py +++ /dev/null @@ -1,79 +0,0 @@ -from django.urls import path - - -from plane.api.views import ( - PageViewSet, - PageBlockViewSet, - PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, -) - - -urlpatterns = [ - path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//page-blocks/", - PageBlockViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-page-blocks", - ), - path( - "workspaces//projects//pages//page-blocks//", - PageBlockViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-page-blocks", - ), - path( - "workspaces//projects//user-favorite-pages/", - PageFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//user-favorite-pages//", - PageFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//pages//page-blocks//issues/", - CreateIssueFromPageBlockEndpoint.as_view(), - name="page-block-issues", - ), -] diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index 2d9e513df..c73e84c89 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -1,132 +1,16 @@ from django.urls import path -from plane.api.views import ( - ProjectViewSet, - InviteProjectEndpoint, - ProjectMemberViewSet, - ProjectMemberInvitationsViewset, - ProjectMemberUserEndpoint, - ProjectJoinEndpoint, - AddTeamToProjectEndpoint, - ProjectUserViewsEndpoint, - ProjectIdentifierEndpoint, - ProjectFavoritesViewSet, - LeaveProjectEndpoint, - ProjectPublicCoverImagesEndpoint, -) - +from plane.api.views import ProjectAPIEndpoint urlpatterns = [ - path( + path( "workspaces//projects/", - ProjectViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ProjectAPIEndpoint.as_view(), name="project", ), path( - "workspaces//projects//", - ProjectViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), + "workspaces//projects//", + ProjectAPIEndpoint.as_view(), name="project", ), - path( - "workspaces//project-identifiers/", - ProjectIdentifierEndpoint.as_view(), - name="project-identifiers", - ), - path( - "workspaces//projects//invite/", - InviteProjectEndpoint.as_view(), - name="invite-project", - ), - path( - "workspaces//projects//members/", - ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), - name="project-member", - ), - path( - "workspaces//projects//members//", - ProjectMemberViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-member", - ), - path( - "workspaces//projects/join/", - ProjectJoinEndpoint.as_view(), - name="project-join", - ), - path( - "workspaces//projects//team-invite/", - AddTeamToProjectEndpoint.as_view(), - name="projects", - ), - path( - "workspaces//projects//invitations/", - ProjectMemberInvitationsViewset.as_view({"get": "list"}), - name="project-member-invite", - ), - path( - "workspaces//projects//invitations//", - ProjectMemberInvitationsViewset.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), - name="project-member-invite", - ), - path( - "workspaces//projects//project-views/", - ProjectUserViewsEndpoint.as_view(), - name="project-view", - ), - path( - "workspaces//projects//project-members/me/", - ProjectMemberUserEndpoint.as_view(), - name="project-member-view", - ), - path( - "workspaces//user-favorite-projects/", - ProjectFavoritesViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-favorite", - ), - path( - "workspaces//user-favorite-projects//", - ProjectFavoritesViewSet.as_view( - { - "delete": "destroy", - } - ), - name="project-favorite", - ), - path( - "workspaces//projects//members/leave/", - LeaveProjectEndpoint.as_view(), - name="leave-project", - ), - path( - "project-covers/", - ProjectPublicCoverImagesEndpoint.as_view(), - name="project-covers", - ), -] +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/public_board.py b/apiserver/plane/api/urls/public_board.py deleted file mode 100644 index 272d5961c..000000000 --- a/apiserver/plane/api/urls/public_board.py +++ /dev/null @@ -1,151 +0,0 @@ -from django.urls import path - - -from plane.api.views import ( - ProjectDeployBoardViewSet, - ProjectDeployBoardPublicSettingsEndpoint, - ProjectIssuesPublicEndpoint, - IssueRetrievePublicEndpoint, - IssueCommentPublicViewSet, - IssueReactionPublicViewSet, - CommentReactionPublicViewSet, - InboxIssuePublicViewSet, - IssueVotePublicViewSet, - WorkspaceProjectDeployBoardEndpoint, -) - - -urlpatterns = [ - path( - "workspaces//projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-deploy-board", - ), - path( - "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-deploy-board", - ), - path( - "public/workspaces//project-boards//settings/", - ProjectDeployBoardPublicSettingsEndpoint.as_view(), - name="project-deploy-board-settings", - ), - path( - "public/workspaces//project-boards//issues/", - ProjectIssuesPublicEndpoint.as_view(), - name="project-deploy-board", - ), - path( - "public/workspaces//project-boards//issues//", - IssueRetrievePublicEndpoint.as_view(), - name="workspace-project-boards", - ), - path( - "public/workspaces//project-boards//issues//comments/", - IssueCommentPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="issue-comments-project-board", - ), - path( - "public/workspaces//project-boards//issues//comments//", - IssueCommentPublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="issue-comments-project-board", - ), - path( - "public/workspaces//project-boards//issues//reactions/", - IssueReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="issue-reactions-project-board", - ), - path( - "public/workspaces//project-boards//issues//reactions//", - IssueReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), - name="issue-reactions-project-board", - ), - path( - "public/workspaces//project-boards//comments//reactions/", - CommentReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="comment-reactions-project-board", - ), - path( - "public/workspaces//project-boards//comments//reactions//", - CommentReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), - name="comment-reactions-project-board", - ), - path( - "public/workspaces//project-boards//inboxes//inbox-issues/", - InboxIssuePublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox-issue", - ), - path( - "public/workspaces//project-boards//inboxes//inbox-issues//", - InboxIssuePublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox-issue", - ), - path( - "public/workspaces//project-boards//issues//votes/", - IssueVotePublicViewSet.as_view( - { - "get": "list", - "post": "create", - "delete": "destroy", - } - ), - name="issue-vote-project-board", - ), - path( - "public/workspaces//project-boards/", - WorkspaceProjectDeployBoardEndpoint.as_view(), - name="workspace-project-boards", - ), -] diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index 94aa55f24..0676ac5ad 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -1,38 +1,16 @@ from django.urls import path - -from plane.api.views import StateViewSet - +from plane.api.views import StateAPIEndpoint urlpatterns = [ path( "workspaces//projects//states/", - StateViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-states", + StateAPIEndpoint.as_view(), + name="states", ), path( - "workspaces//projects//states//", - StateViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-state", + "workspaces//projects//states//", + StateAPIEndpoint.as_view(), + name="states", ), - path( - "workspaces//projects//states//mark-default/", - StateViewSet.as_view( - { - "post": "mark_as_default", - } - ), - name="project-state", - ), -] +] \ No newline at end of file diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index ca66ce48e..84d8dcabb 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -1,180 +1,21 @@ -from .project import ( - ProjectViewSet, - ProjectMemberViewSet, - UserProjectInvitationsViewset, - InviteProjectEndpoint, - AddTeamToProjectEndpoint, - ProjectMemberInvitationsViewset, - ProjectMemberInviteDetailViewSet, - ProjectIdentifierEndpoint, - ProjectJoinEndpoint, - ProjectUserViewsEndpoint, - ProjectMemberUserEndpoint, - ProjectFavoritesViewSet, - ProjectDeployBoardViewSet, - ProjectDeployBoardPublicSettingsEndpoint, - WorkspaceProjectDeployBoardEndpoint, - LeaveProjectEndpoint, - ProjectPublicCoverImagesEndpoint, -) -from .user import ( - UserEndpoint, - UpdateUserOnBoardedEndpoint, - UpdateUserTourCompletedEndpoint, - UserActivityEndpoint, -) +from .project import ProjectAPIEndpoint -from .oauth import OauthEndpoint +from .state import StateAPIEndpoint -from .base import BaseAPIView, BaseViewSet - -from .workspace import ( - WorkSpaceViewSet, - UserWorkSpacesEndpoint, - WorkSpaceAvailabilityCheckEndpoint, - InviteWorkspaceEndpoint, - JoinWorkspaceEndpoint, - WorkSpaceMemberViewSet, - TeamMemberViewSet, - WorkspaceInvitationsViewset, - UserWorkspaceInvitationsEndpoint, - UserWorkspaceInvitationEndpoint, - UserLastProjectWithWorkspaceEndpoint, - WorkspaceMemberUserEndpoint, - WorkspaceMemberUserViewsEndpoint, - UserActivityGraphEndpoint, - UserIssueCompletedGraphEndpoint, - UserWorkspaceDashboardEndpoint, - WorkspaceThemeViewSet, - WorkspaceUserProfileStatsEndpoint, - WorkspaceUserActivityEndpoint, - WorkspaceUserProfileEndpoint, - WorkspaceUserProfileIssuesEndpoint, - WorkspaceLabelsEndpoint, - LeaveWorkspaceEndpoint, -) -from .state import StateViewSet -from .view import ( - GlobalViewViewSet, - GlobalViewIssuesViewSet, - IssueViewViewSet, - IssueViewFavoriteViewSet, -) -from .cycle import ( - CycleViewSet, - CycleIssueViewSet, - CycleDateCheckEndpoint, - CycleFavoriteViewSet, - TransferCycleIssueEndpoint, -) -from .asset import FileAssetEndpoint, UserAssetsEndpoint from .issue import ( - IssueViewSet, - IssueListEndpoint, - IssueListGroupedEndpoint, - WorkSpaceIssuesEndpoint, - IssueActivityEndpoint, - IssueCommentViewSet, - IssueUserDisplayPropertyEndpoint, - LabelViewSet, - BulkDeleteIssuesEndpoint, - UserWorkSpaceIssues, - SubIssuesEndpoint, - IssueLinkViewSet, - BulkCreateIssueLabelsEndpoint, - IssueAttachmentEndpoint, - IssueArchiveViewSet, - IssueSubscriberViewSet, - IssueCommentPublicViewSet, - CommentReactionViewSet, - IssueReactionViewSet, - IssueReactionPublicViewSet, - CommentReactionPublicViewSet, - IssueVotePublicViewSet, - IssueRelationViewSet, - IssueRetrievePublicEndpoint, - ProjectIssuesPublicEndpoint, - IssueDraftViewSet, + IssueAPIEndpoint, + LabelAPIEndpoint, + IssueLinkAPIEndpoint, + IssueCommentAPIEndpoint, + IssueActivityAPIEndpoint, ) -from .auth_extended import ( - VerifyEmailEndpoint, - RequestEmailVerificationEndpoint, - ForgotPasswordEndpoint, - ResetPasswordEndpoint, - ChangePasswordEndpoint, +from .cycle import ( + CycleAPIEndpoint, + CycleIssueAPIEndpoint, + TransferCycleIssueAPIEndpoint, ) +from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint -from .authentication import ( - SignUpEndpoint, - SignInEndpoint, - SignOutEndpoint, - MagicSignInEndpoint, - MagicSignInGenerateEndpoint, -) - -from .module import ( - ModuleViewSet, - ModuleIssueViewSet, - ModuleLinkViewSet, - ModuleFavoriteViewSet, -) - -from .api_token import ApiTokenEndpoint - -from .integration import ( - WorkspaceIntegrationViewSet, - IntegrationViewSet, - GithubIssueSyncViewSet, - GithubRepositorySyncViewSet, - GithubCommentSyncViewSet, - GithubRepositoriesEndpoint, - BulkCreateGithubIssueSyncEndpoint, - SlackProjectSyncViewSet, -) - -from .importer import ( - ServiceIssueImportSummaryEndpoint, - ImportServiceEndpoint, - UpdateServiceImportStatusEndpoint, - BulkImportIssuesEndpoint, - BulkImportModulesEndpoint, -) - -from .page import ( - PageViewSet, - PageBlockViewSet, - PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, -) - -from .search import GlobalSearchEndpoint, IssueSearchEndpoint - - -from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint - -from .estimate import ( - ProjectEstimatePointEndpoint, - BulkEstimatePointEndpoint, -) - -from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet - -from .analytic import ( - AnalyticsEndpoint, - AnalyticViewViewset, - SavedAnalyticEndpoint, - ExportAnalyticsEndpoint, - DefaultAnalyticsEndpoint, -) - -from .notification import ( - NotificationViewSet, - UnreadNotificationEndpoint, - MarkAllReadNotificationViewSet, -) - -from .exporter import ExportIssuesEndpoint - -from .config import ConfigurationEndpoint +from .inbox import InboxIssueAPIEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/api_token.py b/apiserver/plane/api/views/api_token.py deleted file mode 100644 index 2253903a9..000000000 --- a/apiserver/plane/api/views/api_token.py +++ /dev/null @@ -1,47 +0,0 @@ -# Python import -from uuid import uuid4 - -# Third party -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module import -from .base import BaseAPIView -from plane.db.models import APIToken -from plane.api.serializers import APITokenSerializer - - -class ApiTokenEndpoint(BaseAPIView): - def post(self, request): - label = request.data.get("label", str(uuid4().hex)) - workspace = request.data.get("workspace", False) - - if not workspace: - return Response( - {"error": "Workspace is required"}, status=status.HTTP_200_OK - ) - - api_token = APIToken.objects.create( - label=label, user=request.user, workspace_id=workspace - ) - - serializer = APITokenSerializer(api_token) - # Token will be only vissible while creating - return Response( - {"api_token": serializer.data, "token": api_token.token}, - status=status.HTTP_201_CREATED, - ) - - - def get(self, request): - api_tokens = APIToken.objects.filter(user=request.user) - serializer = APITokenSerializer(api_tokens, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - - def delete(self, request, pk): - api_token = APIToken.objects.get(pk=pk) - api_token.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py deleted file mode 100644 index fbffacff8..000000000 --- a/apiserver/plane/api/views/auth_extended.py +++ /dev/null @@ -1,151 +0,0 @@ -## 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.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 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( - {"message": "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): - 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) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py deleted file mode 100644 index eadfeef61..000000000 --- a/apiserver/plane/api/views/authentication.py +++ /dev/null @@ -1,397 +0,0 @@ -# Python imports -import uuid -import random -import string -import json -import requests - -# 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 -from django.contrib.auth.hashers import make_password - -# 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): - if not settings.ENABLE_SIGNUP: - return Response( - { - "error": "New account creation is disabled. Please contact your site administrator" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - 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, - ) - - # Check if the user already exists - if User.objects.filter(email=email).exists(): - return Response( - {"error": "User with this email already exists"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user = User.objects.create(email=email, username=uuid.uuid4().hex) - 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() - - access_token, refresh_token = get_tokens_for_user(user) - - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_UP", - }, - ) - - return Response(data, status=status.HTTP_200_OK) - - -class SignInEndpoint(BaseAPIView): - permission_classes = (AllowAny,) - - def post(self, request): - 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 None: - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - - # Sign up Process - 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, - ) - - # 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) - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_IN", - }, - ) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } - - return Response(data, status=status.HTTP_200_OK) - - -class SignOutEndpoint(BaseAPIView): - def post(self, request): - refresh_token = request.data.get("refresh_token", False) - - if not refresh_token: - capture_message("No refresh token provided") - return Response( - {"error": "No refresh token provided"}, - 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) - - -class MagicSignInGenerateEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - email = request.data.get("email", False) - - if not email: - return Response( - {"error": "Please provide a valid email address"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Clean up - email = email.strip().lower() - validate_email(email) - - ## Generate a random token - token = ( - "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, k=4)) - + "-" - + "".join(random.choices(string.ascii_lowercase, 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) - - -class MagicSignInEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def post(self, request): - user_token = request.data.get("token", "").strip() - key = request.data.get("key", False).strip().lower() - - 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) - # Send event to Jitsu for tracking - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "code", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_IN", - }, - ) - else: - user = User.objects.create( - email=email, - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - ) - # Send event to Jitsu for tracking - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "code", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_UP", - }, - ) - - 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, - } - - 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, - ) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 7ab660e81..abde4e8b0 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,27 +1,25 @@ # Python imports import zoneinfo +import json # Django imports -from django.urls import resolve from django.conf import settings -from django.utils import timezone from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.utils import timezone -# Third part imports -from rest_framework import status -from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response -from rest_framework.exceptions import APIException +# Third party imports from rest_framework.views import APIView -from rest_framework.filters import SearchFilter +from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated +from rest_framework import status from sentry_sdk import capture_exception -from django_filters.rest_framework import DjangoFilterBackend # Module imports +from plane.api.middleware.api_authentication import APIKeyAuthentication +from plane.api.rate_limit import ApiKeyRateThrottle from plane.utils.paginator import BasePaginator +from plane.bgtasks.webhook_task import send_webhook class TimezoneMixin: @@ -29,6 +27,7 @@ class TimezoneMixin: This enables timezone conversion according to the user set timezone """ + def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) if request.user.is_authenticated: @@ -37,109 +36,50 @@ class TimezoneMixin: timezone.deactivate() -class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): +class WebhookMixin: + webhook_event = None + bulk = False - model = None + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) - permission_classes = [ - IsAuthenticated, - ] + # Check for the case should webhook be sent + if ( + self.webhook_event + and self.request.method in ["POST", "PATCH", "DELETE"] + and response.status_code in [200, 201, 204] + ): + # Push the object to delay + send_webhook.delay( + event=self.webhook_event, + payload=response.data, + kw=self.kwargs, + action=self.request.method, + slug=self.workspace_slug, + bulk=self.bulk, + ) - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) - - filterset_fields = [] - - search_fields = [] - - def get_queryset(self): - try: - return self.model.objects.all() - except Exception as e: - capture_exception(e) - raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) - - def handle_exception(self, exc): - """ - Handle any exception that occurs, by returning an appropriate response, - or re-raising the error. - """ - try: - response = super().handle_exception(exc) - return response - except Exception as e: - if isinstance(e, IntegrityError): - return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) - - if isinstance(e, ValidationError): - return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) - - if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] - return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) - - if isinstance(e, KeyError): - capture_exception(e) - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) - - print(e) if settings.DEBUG else print("Server Error") - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - - def dispatch(self, request, *args, **kwargs): - try: - response = super().dispatch(request, *args, **kwargs) - - if settings.DEBUG: - from django.db import connection - - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) - return response - - except Exception as exc: - response = self.handle_exception(exc) - return exc - - @property - def workspace_slug(self): - return self.kwargs.get("slug", 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) + return response class BaseAPIView(TimezoneMixin, APIView, BasePaginator): + authentication_classes = [ + APIKeyAuthentication, + ] permission_classes = [ IsAuthenticated, ] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) - - filterset_fields = [] - - search_fields = [] + throttle_classes = [ + ApiKeyRateThrottle, + ] def filter_queryset(self, queryset): for backend in list(self.filter_backends): queryset = backend().filter_queryset(self.request, queryset, self) return queryset - def handle_exception(self, exc): """ Handle any exception that occurs, by returning an appropriate response, @@ -150,27 +90,43 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): return response except Exception as e: if isinstance(e, IntegrityError): - return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ValidationError): - return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + { + "error": "The provided payload is not valid please try with a valid payload" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ObjectDoesNotExist): model_name = str(exc).split(" matching query does not exist.")[0] - return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) - - if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) - - print(e) if settings.DEBUG else print("Server Error") - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + if isinstance(e, KeyError): + return Response( + {"error": f"key {e} does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if settings.DEBUG: + print(e) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) def dispatch(self, request, *args, **kwargs): try: response = super().dispatch(request, *args, **kwargs) - if settings.DEBUG: from django.db import connection @@ -178,11 +134,25 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" ) return response - except Exception as exc: response = self.handle_exception(exc) return exc + def finalize_response(self, request, response, *args, **kwargs): + # Call super to get the default response + response = super().finalize_response(request, response, *args, **kwargs) + + # Add custom headers if they exist in the request META + ratelimit_remaining = request.META.get("X-RateLimit-Remaining") + if ratelimit_remaining is not None: + response["X-RateLimit-Remaining"] = ratelimit_remaining + + ratelimit_reset = request.META.get("X-RateLimit-Reset") + if ratelimit_reset is not None: + response["X-RateLimit-Reset"] = ratelimit_reset + + return response + @property def workspace_slug(self): return self.kwargs.get("slug", None) @@ -190,3 +160,17 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def project_id(self): return self.kwargs.get("project_id", None) + + @property + def fields(self): + fields = [ + field for field in self.request.GET.get("fields", "").split(",") if field + ] + return fields if fields else None + + @property + def expand(self): + expand = [ + expand for expand in self.request.GET.get("expand", "").split(",") if expand + ] + return expand if expand else None diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py deleted file mode 100644 index d035c4740..000000000 --- a/apiserver/plane/api/views/config.py +++ /dev/null @@ -1,37 +0,0 @@ -# Python imports -import os - -# Django imports -from django.conf import settings - -# Third party imports -from rest_framework.permissions import AllowAny -from rest_framework import status -from rest_framework.response import Response -from sentry_sdk import capture_exception - -# Module imports -from .base import BaseAPIView - - -class ConfigurationEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request): - data = {} - data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None) - data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None) - data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) - data["magic_login"] = ( - bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) - ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" - data["email_password_login"] = ( - os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" - ) - data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None) - data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None) - data["posthog_host"] = os.environ.get("POSTHOG_HOST", None) - data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY) - return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 21defcc13..310332333 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -2,81 +2,47 @@ import json # Django imports -from django.db.models import ( - Func, - F, - Q, - Exists, - OuterRef, - Count, - Prefetch, - Sum, -) -from django.core import serializers +from django.db.models import Q, Count, Sum, Prefetch, F, OuterRef, Func from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page +from django.core import serializers # 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 .base import BaseAPIView, WebhookMixin +from plane.db.models import Cycle, Issue, CycleIssue, IssueLink, IssueAttachment +from plane.app.permissions import ProjectEntityPermission from plane.api.serializers import ( CycleSerializer, CycleIssueSerializer, - CycleFavoriteSerializer, - IssueStateSerializer, - CycleWriteSerializer, -) -from plane.api.permissions import ProjectEntityPermission -from plane.db.models import ( - User, - Cycle, - CycleIssue, - Issue, - CycleFavorite, - IssueLink, - IssueAttachment, - Label, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters -from plane.utils.analytics_plot import burndown_plot -class CycleViewSet(BaseViewSet): +class CycleAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to cycle. + + """ + serializer_class = CycleSerializer model = Cycle + webhook_event = "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): - subquery = CycleFavorite.objects.filter( - user=self.request.user, - cycle_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + Cycle.objects.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("owned_by") - .annotate(is_favorite=Exists(subquery)) .annotate( total_issues=Count( "issue_cycle", @@ -157,142 +123,62 @@ class CycleViewSet(BaseViewSet): ), ) ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only("avatar", "first_name", "id").distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only("name", "color", "id").distinct(), - ) - ) - .order_by("-is_favorite", "name") + .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) - def list(self, request, slug, project_id): + def get(self, request, slug, project_id, pk=None): + if pk: + queryset = self.get_queryset().get(pk=pk) + data = CycleSerializer( + queryset, + fields=self.fields, + expand=self.expand, + ).data + return Response( + data, + status=status.HTTP_200_OK, + ) queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - queryset = queryset.order_by("-is_favorite","-created_at") - # Current Cycle if cycle_view == "current": queryset = queryset.filter( start_date__lte=timezone.now(), end_date__gte=timezone.now(), ) - - data = CycleSerializer(queryset, many=True).data - - if len(data): - assignee_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=data[0]["id"], - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - - label_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=data[0]["id"], - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - data[0]["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], - ) - + data = CycleSerializer( + queryset, many=True, fields=self.fields, expand=self.expand + ).data return Response(data, status=status.HTTP_200_OK) # Upcoming Cycles if cycle_view == "upcoming": queryset = queryset.filter(start_date__gt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) # Completed Cycles if cycle_view == "completed": queryset = queryset.filter(end_date__lt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) # Draft Cycles @@ -301,9 +187,15 @@ class CycleViewSet(BaseViewSet): end_date=None, start_date=None, ) - - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) # Incomplete Cycles @@ -311,16 +203,28 @@ class CycleViewSet(BaseViewSet): queryset = queryset.filter( Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), ) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - - # If no matching view is found return all cycles - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - def create(self, request, slug, project_id): + def post(self, request, slug, project_id): if ( request.data.get("start_date", None) is None and request.data.get("end_date", None) is None @@ -344,7 +248,7 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def partial_update(self, request, slug, project_id, pk): + def patch(self, request, slug, project_id, pk): cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) request_data = request.data @@ -363,115 +267,13 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) + serializer = CycleSerializer(cycle, data=request.data, 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) - def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - - # Assignee Distribution - assignee_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate(display_name=F("assignees__display_name")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") - ) - - # Label Distribution - label_distribution = ( - Issue.objects.filter( - issue_cycle__cycle_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q(archived_at__isnull=True, is_draft=False), - ), - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data = CycleSerializer(queryset).data - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.end_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, pk): + def delete(self, request, slug, project_id, pk): cycle_issues = list( CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( "issue", flat=True @@ -489,7 +291,7 @@ class CycleViewSet(BaseViewSet): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -499,24 +301,24 @@ class CycleViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleIssueViewSet(BaseViewSet): +class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. + + """ + serializer_class = CycleIssueSerializer model = CycleIssue - + webhook_event = "cycle_issue" + bulk = True permission_classes = [ ProjectEntityPermission, ] - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( + return ( + CycleIssue.objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -531,15 +333,12 @@ class CycleIssueViewSet(BaseViewSet): .select_related("cycle") .select_related("issue", "issue__state", "issue__project") .prefetch_related("issue__assignees", "issue__labels") + .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) - @method_decorator(gzip_page) - def list(self, request, slug, project_id, cycle_id): + def get(self, request, slug, project_id, cycle_id): order_by = request.GET.get("order_by", "created_at") - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( @@ -558,7 +357,6 @@ class CycleIssueViewSet(BaseViewSet): .prefetch_related("assignees") .prefetch_related("labels") .order_by(order_by) - .filter(**filters) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -573,29 +371,21 @@ class CycleIssueViewSet(BaseViewSet): ) ) - issues_data = IssueStateSerializer(issues, many=True).data - - if sub_group_by and sub_group_by == group_by: - return Response( - {"error": "Group by and sub group by cannot be same"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if group_by: - grouped_results = group_results(issues_data, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response( - issues_data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: CycleSerializer( + issues, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - def create(self, request, slug, project_id, cycle_id): + def post(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) @@ -612,6 +402,10 @@ class CycleIssueViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + issues = Issue.objects.filter( + pk__in=issues, workspace__slug=slug, project_id=project_id + ).values_list("id", flat=True) + # Get all CycleIssues already created cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) update_cycle_issue_activity = [] @@ -662,7 +456,7 @@ class CycleIssueViewSet(BaseViewSet): # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": issues}), + requested_data=json.dumps({"cycles_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -683,9 +477,9 @@ class CycleIssueViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - def destroy(self, request, slug, project_id, cycle_id, pk): + def delete(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id ) issue_id = cycle_issue.issue_id cycle_issue.delete() @@ -698,7 +492,7 @@ class CycleIssueViewSet(BaseViewSet): } ), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=str(issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -706,74 +500,12 @@ class CycleIssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleDateCheckEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] +class TransferCycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `create` actions for transfering the issues into a particular cycle. - def post(self, request, slug, project_id): - start_date = request.data.get("start_date", False) - end_date = request.data.get("end_date", False) - cycle_id = request.data.get("cycle_id") - if not start_date or not end_date: - return Response( - {"error": "Start date and end date both are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) + """ - cycles = Cycle.objects.filter( - Q(workspace__slug=slug) - & Q(project_id=project_id) - & ( - Q(start_date__lte=start_date, end_date__gte=start_date) - | Q(start_date__lte=end_date, end_date__gte=end_date) - | Q(start_date__gte=start_date, end_date__lte=end_date) - ) - ).exclude(pk=cycle_id) - - if cycles.exists(): - return Response( - { - "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", - "status": False, - } - ) - else: - return Response({"status": True}, status=status.HTTP_200_OK) - - -class CycleFavoriteViewSet(BaseViewSet): - serializer_class = CycleFavoriteSerializer - model = CycleFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("cycle", "cycle__owned_by") - ) - - def create(self, request, slug, project_id): - serializer = CycleFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, cycle_id): - cycle_favorite = CycleFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - cycle_id=cycle_id, - ) - cycle_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TransferCycleIssueEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] @@ -818,4 +550,4 @@ class TransferCycleIssueEndpoint(BaseAPIView): updated_cycles, ["cycle_id"], batch_size=100 ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) + return Response({"message": "Success"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 517e9b6de..4f4cdc4ef 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -1,83 +1,30 @@ # Python imports import json -# Django import +# Django improts from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status from rest_framework.response import Response -from sentry_sdk import capture_exception # Module imports -from .base import BaseViewSet -from plane.api.permissions import ProjectBasePermission, ProjectLitePermission -from plane.db.models import ( - Inbox, - InboxIssue, - Issue, - State, - IssueLink, - IssueAttachment, - ProjectMember, - ProjectDeployBoard, -) -from plane.api.serializers import ( - IssueSerializer, - InboxSerializer, - InboxIssueSerializer, - IssueCreateSerializer, - IssueStateInboxSerializer, -) -from plane.utils.issue_filters import issue_filters +from .base import BaseAPIView +from plane.app.permissions import ProjectLitePermission +from plane.api.serializers import InboxIssueSerializer, IssueSerializer +from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox from plane.bgtasks.issue_activites_task import issue_activity -class InboxViewSet(BaseViewSet): - permission_classes = [ - ProjectBasePermission, - ] +class InboxIssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to inbox issues. - serializer_class = InboxSerializer - model = Inbox + """ - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .annotate( - pending_issue_count=Count( - "issue_inbox", - filter=Q(issue_inbox__status=-2), - ) - ) - .select_related("workspace", "project") - ) - - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - - def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - # Handle default inbox delete - if inbox.is_default: - return Response( - {"error": "You cannot delete the default inbox"}, - status=status.HTTP_400_BAD_REQUEST, - ) - inbox.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class InboxIssueViewSet(BaseViewSet): permission_classes = [ ProjectLitePermission, ] @@ -90,73 +37,77 @@ class InboxIssueViewSet(BaseViewSet): ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter( + inbox = Inbox.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ).first() + + project = Project.objects.get( + workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") + ) + + if inbox is None and not project.inbox_view: + return InboxIssue.objects.none() + + return ( + InboxIssue.objects.filter( Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), - inbox_id=self.kwargs.get("inbox_id"), + inbox_id=inbox.id, ) .select_related("issue", "workspace", "project") + .order_by(self.kwargs.get("order_by", "-created_at")) ) - def list(self, request, slug, project_id, inbox_id): - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.objects.filter( - issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, + def get(self, request, slug, project_id, issue_id=None): + if issue_id: + inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id) + inbox_issue_data = InboxIssueSerializer( + inbox_issue_queryset, + fields=self.fields, + expand=self.expand, + ).data + return Response( + inbox_issue_data, + status=status.HTTP_200_OK, ) - .filter(**filters) - .annotate(bridge_id=F("issue_inbox__id")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), - ) - ) - ) - issues_data = IssueStateInboxSerializer(issues, many=True).data - return Response( - issues_data, - status=status.HTTP_200_OK, + issue_queryset = self.get_queryset() + return self.paginate( + request=request, + queryset=(issue_queryset), + on_results=lambda inbox_issues: InboxIssueSerializer( + inbox_issues, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - - def create(self, request, slug, project_id, inbox_id): + def post(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, + ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project's api" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check for valid priority if not request.data.get("issue", {}).get("priority", "none") in [ "low", @@ -198,48 +149,83 @@ class InboxIssueViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) + # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox.id, project_id=project_id, issue=issue, source=request.data.get("source", "in-app"), ) - serializer = IssueStateInboxSerializer(issue) + serializer = InboxIssueSerializer(inbox_issue) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + def patch(self, request, slug, project_id, issue_id): + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project's api" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the inbox issue + inbox_issue = InboxIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox.id, + ) + # Get the project member - project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + # Only project members admins and created_by users can access this endpoint - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Get issue data issue_data = request.data.pop("issue", False) if bool(issue_data): issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=issue_id, workspace__slug=slug, project_id=project_id ) # Only allow guests and viewers to edit name and description if project_member.role <= 10: - # viewers and guests since only viewers and guests + # viewers and guests since only viewers and guests issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description) + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get("description", issue.description), } - issue_serializer = IssueCreateSerializer( - issue, data=issue_data, partial=True - ) + issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) if issue_serializer.is_valid(): current_instance = issue @@ -250,13 +236,13 @@ class InboxIssueViewSet(BaseViewSet): type="issue.activity.updated", requested_data=requested_data, actor_id=str(request.user.id), - issue_id=str(issue.id), + issue_id=str(issue_id), project_id=str(project_id), current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) issue_serializer.save() else: @@ -275,7 +261,7 @@ class InboxIssueViewSet(BaseViewSet): # Update the issue state if the issue is rejected or marked as duplicate if serializer.data["status"] in [-1, 2]: issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=issue_id, workspace__slug=slug, project_id=project_id, ) @@ -289,7 +275,7 @@ class InboxIssueViewSet(BaseViewSet): # Update the issue state if it is accepted if serializer.data["status"] in [1]: issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=issue_id, workspace__slug=slug, project_id=project_id, ) @@ -307,253 +293,60 @@ class InboxIssueViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: - return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK) + return Response( + InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + ) - def retrieve(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) + def delete(self, request, slug, project_id, issue_id): + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() - def destroy(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project's api" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the inbox issue + inbox_issue = InboxIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox.id, + ) + # Get the project member - project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) - if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + # Check the inbox issue created + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Check the issue status if inbox_issue.status in [-2, -1, 0, 2]: # Delete the issue also - Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete() - - inbox_issue.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class InboxIssuePublicViewSet(BaseViewSet): - serializer_class = InboxIssueSerializer - model = InboxIssue - - filterset_fields = [ - "status", - ] - - def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id")) - if project_deploy_board is not None: - return self.filter_queryset( - super() - .get_queryset() - .filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - inbox_id=self.kwargs.get("inbox_id"), - ) - .select_related("issue", "workspace", "project") - ) - return InboxIssue.objects.none() - - def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) - if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - - filters = issue_filters(request.query_params, "GET") - issues = ( Issue.objects.filter( - issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, - ) - .filter(**filters) - .annotate(bridge_id=F("issue_inbox__id")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), - ) - ) - ) - issues_data = IssueStateInboxSerializer(issues, many=True).data - return Response( - issues_data, - status=status.HTTP_200_OK, - ) - - def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) - if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - - if not request.data.get("issue", {}).get("name", False): - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Check for valid priority - if not request.data.get("issue", {}).get("priority", "none") in [ - "low", - "medium", - "high", - "urgent", - "none", - ]: - return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST - ) - - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="backlog", - description="Default state for managing all Inbox Issues", - project_id=project_id, - color="#ff7700", - ) - - # create an issue - issue = Issue.objects.create( - name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get( - "description_html", "

" - ), - priority=request.data.get("issue", {}).get("priority", "low"), - project_id=project_id, - state=state, - ) - - # Create an Issue Activity - issue_activity.delay( - type="issue.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()) - ) - # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), - ) - - serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) - if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - # Get the project member - if str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) - - # Get issue data - issue_data = request.data.pop("issue", False) - - - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - # viewers and guests since only viewers and guests - issue_data = { - "name": issue_data.get("name", issue.name), - "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description) - } - - issue_serializer = IssueCreateSerializer( - issue, data=issue_data, partial=True - ) - - if issue_serializer.is_valid(): - current_instance = issue - # Log all the updates - requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) - if issue is not None: - issue_activity.delay( - type="issue.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(current_instance).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()) - ) - issue_serializer.save() - return Response(issue_serializer.data, status=status.HTTP_200_OK) - return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) - if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueStateInboxSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) - if project_deploy_board.inbox is None: - return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST) - - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - - if str(inbox_issue.created_by_id) != str(request.user.id): - return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + workspace__slug=slug, project_id=project_id, pk=issue_id + ).delete() inbox_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index d1cd93e73..41745010f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,111 +1,68 @@ # Python imports import json -import random from itertools import chain # Django imports -from django.utils import timezone +from django.db import IntegrityError from django.db.models import ( - Prefetch, OuterRef, Func, - F, Q, - Count, + F, Case, + When, Value, CharField, - When, - Exists, Max, - IntegerField, + Exists, ) from django.core.serializers.json import DjangoJSONEncoder -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page -from django.db import IntegrityError +from django.utils import timezone -# Third Party imports -from rest_framework.response import Response +# Third party imports from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser -from rest_framework.permissions import AllowAny, IsAuthenticated -from sentry_sdk import capture_exception +from rest_framework.response import Response # Module imports -from . import BaseViewSet, BaseAPIView -from plane.api.serializers import ( - IssueCreateSerializer, - IssueActivitySerializer, - IssueCommentSerializer, - IssuePropertySerializer, - IssueSerializer, - LabelSerializer, - IssueFlatSerializer, - IssueLinkSerializer, - IssueLiteSerializer, - IssueAttachmentSerializer, - IssueSubscriberSerializer, - ProjectMemberLiteSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssueRelationSerializer, - RelatedIssueSerializer, - IssuePublicSerializer, -) -from plane.api.permissions import ( +from .base import BaseAPIView, WebhookMixin +from plane.app.permissions import ( ProjectEntityPermission, - WorkSpaceAdminPermission, ProjectMemberPermission, ProjectLitePermission, ) from plane.db.models import ( - Project, Issue, - IssueActivity, - IssueComment, - IssueProperty, - Label, - IssueLink, IssueAttachment, - State, - IssueSubscriber, + IssueLink, + Project, + Label, ProjectMember, - IssueReaction, - CommentReaction, - ProjectDeployBoard, - IssueVote, - IssueRelation, - ProjectPublicMember, + IssueComment, + IssueActivity, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters +from plane.api.serializers import ( + IssueSerializer, + LabelSerializer, + IssueLinkSerializer, + IssueCommentSerializer, + IssueActivitySerializer, +) -class IssueViewSet(BaseViewSet): - def get_serializer_class(self): - return ( - IssueCreateSerializer - if self.action in ["create", "update", "partial_update"] - else IssueSerializer - ) +class IssueAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to issue. + + """ model = Issue + webhook_event = "issue" permission_classes = [ ProjectEntityPermission, ] - - search_fields = [ - "name", - ] - - filterset_fields = [ - "state__name", - "assignees__id", - "workspace__id", - ] + serializer_class = IssueSerializer def get_queryset(self): return ( @@ -123,17 +80,25 @@ class IssueViewSet(BaseViewSet): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) + .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") + def get(self, request, slug, project_id, pk=None): + if pk: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + return Response( + IssueSerializer( + issue, + fields=self.fields, + expand=self.expand, + ).data, + status=status.HTTP_200_OK, + ) # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] @@ -143,7 +108,6 @@ class IssueViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() - .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(module_id=F("issue_module__module_id")) .annotate( @@ -216,30 +180,21 @@ class IssueViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True).data + return self.paginate( + request=request, + queryset=(issue_queryset), + on_results=lambda issues: IssueSerializer( + issues, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) - ## Grouping the results - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - if sub_group_by and sub_group_by == group_by: - return Response( - {"error": "Group by and sub group by cannot be same"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if group_by: - grouped_results = group_results(issues, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response(issues, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id): + def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id) - serializer = IssueCreateSerializer( + serializer = IssueSerializer( data=request.data, context={ "project_id": project_id, @@ -264,22 +219,13 @@ class IssueViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, pk=None): + def patch(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueCreateSerializer(issue, data=request.data, partial=True) + serializer = IssueSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -294,7 +240,7 @@ class IssueViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, pk=None): + def delete(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder @@ -312,324 +258,248 @@ class IssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueListEndpoint(BaseAPIView): +class LabelAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the labels. + + """ + + serializer_class = LabelSerializer + model = Label permission_classes = [ - ProjectEntityPermission, + ProjectMemberPermission, ] - def get(self, request, slug, project_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - filters = issue_filters(request.query_params, "GET") - - issue_queryset = ( - Issue.objects.filter(workspace__slug=slug, project_id=project_id) + def get_queryset(self): + return ( + Label.objects.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("state") .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .distinct() + .order_by(self.kwargs.get("order_by", "-created_at")) ) - serializer = IssueLiteSerializer( - issue_queryset, many=True, fields=fields if fields else None - ) - - return Response(serializer.data, status=status.HTTP_200_OK) - - -class IssueListGroupedEndpoint(BaseAPIView): - - permission_classes = [ - ProjectEntityPermission, - ] - - def get(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - fields = [field for field in request.GET.get("fields", "").split(",") if field] - - issue_queryset = ( - Issue.objects.filter(workspace__slug=slug, project_id=project_id) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .distinct() - ) - - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response( - issue_dict, - status=status.HTTP_200_OK, - ) - - -class UserWorkSpaceIssues(BaseAPIView): - @method_decorator(gzip_page) - def get(self, request, slug): - filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - Issue.issue_objects.filter( - ( - Q(assignees__in=[request.user]) - | Q(created_by=request.user) - | Q(issue_subscribers__subscriber=request.user) - ), - workspace__slug=slug, - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by_param) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .filter(**filters) - ).distinct() - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True).data - - ## Grouping the results - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - if sub_group_by and sub_group_by == group_by: + def post(self, request, slug, project_id): + try: + serializer = LabelSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: return Response( - {"error": "Group by and sub group by cannot be same"}, + {"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST, ) - if group_by: - grouped_results = group_results(issues, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, + def get(self, request, slug, project_id, pk=None): + if pk is None: + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer( + labels, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - - return Response(issues, status=status.HTTP_200_OK) - - -class WorkSpaceIssuesEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] - - @method_decorator(gzip_page) - def get(self, request, slug): - issues = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - ) - serializer = IssueSerializer(issues, many=True) + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,) return Response(serializer.data, status=status.HTTP_200_OK) + def patch(self, request, slug, project_id, pk=None): + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, data=request.data, 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) + + + def delete(self, request, slug, project_id, pk=None): + label = self.get_queryset().get(pk=pk) + label.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueLinkAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the links of the particular issue. + + """ -class IssueActivityEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] - @method_decorator(gzip_page) - def get(self, request, slug, project_id, issue_id): - issue_activities = ( - IssueActivity.objects.filter(issue_id=issue_id) - .filter( - ~Q(field__in=["comment", "vote", "reaction", "draft"]), - project__project_projectmember__member=self.request.user, - ) - .select_related("actor", "workspace", "issue", "project") - ).order_by("created_at") - issue_comments = ( - IssueComment.objects.filter(issue_id=issue_id) + model = IssueLink + serializer_class = IssueLinkSerializer + + def get_queryset(self): + return ( + IssueLink.objects.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) - .order_by("created_at") - .select_related("actor", "issue", "project", "workspace") - .prefetch_related( - Prefetch( - "comment_reactions", - queryset=CommentReaction.objects.select_related("actor"), - ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id, issue_id, pk=None): + if pk is None: + issue_links = self.get_queryset() + serializer = IssueLinkSerializer( + issue_links, + fields=self.fields, + expand=self.expand, ) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + issue_link = self.get_queryset().get(pk=pk) + serializer = IssueLinkSerializer( + issue_link, + fields=self.fields, + expand=self.expand, ) - issue_activities = IssueActivitySerializer(issue_activities, many=True).data - issue_comments = IssueCommentSerializer(issue_comments, many=True).data + return Response(serializer.data, status=status.HTTP_200_OK) - result_list = sorted( - chain(issue_activities, issue_comments), - key=lambda instance: instance["created_at"], + def post(self, request, slug, project_id, issue_id): + serializer = IssueLinkSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + issue_id=issue_id, + ) + issue_activity.delay( + type="link.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, + cls=DjangoJSONEncoder, + ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response(result_list, status=status.HTTP_200_OK) + def delete(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, + cls=DjangoJSONEncoder, + ) + issue_activity.delay( + type="link.activity.deleted", + requested_data=json.dumps({"link_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) -class IssueCommentViewSet(BaseViewSet): +class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to comments of the particular issue. + + """ + serializer_class = IssueCommentSerializer model = IssueComment + webhook_event = "issue_comment" permission_classes = [ ProjectLitePermission, ] - filterset_fields = [ - "issue__id", - "workspace__id", - ] - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + IssueComment.objects.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") + .select_related("actor") .annotate( is_member=Exists( ProjectMember.objects.filter( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), member_id=self.request.user.id, + is_active=True, ) ) ) + .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) - def create(self, request, slug, project_id, issue_id): + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + issue_comment = self.get_queryset().get(pk=pk) + serializer = IssueCommentSerializer( + issue_comment, + fields=self.fields, + expand=self.expand, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_comment: IssueCommentSerializer( + issue_comment, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + def post(self, request, slug, project_id, issue_id): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -649,7 +519,7 @@ class IssueCommentViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def partial_update(self, request, slug, project_id, issue_id, pk): + def patch(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -675,7 +545,7 @@ class IssueCommentViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, pk): + def delete(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -696,1648 +566,35 @@ class IssueCommentViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueUserDisplayPropertyEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] - - def post(self, request, slug, project_id): - 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() - issue_property.properties = request.data.get("properties", {}) - issue_property.save() - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def get(self, request, slug, project_id): - issue_property, _ = IssueProperty.objects.get_or_create( - user=request.user, project_id=project_id - ) - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class LabelViewSet(BaseViewSet): - serializer_class = LabelSerializer - model = Label - permission_classes = [ - ProjectMemberPermission, - ] - - def create(self, request, slug, project_id): - try: - serializer = LabelSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except IntegrityError: - return Response( - {"error": "Label with the same name already exists in the project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - 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") - .order_by("name") - .distinct() - ) - - -class BulkDeleteIssuesEndpoint(BaseAPIView): +class IssueActivityAPIEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] - def delete(self, request, slug, project_id): - 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, + def get(self, request, slug, project_id, issue_id, pk=None): + issue_activities = ( + IssueActivity.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id ) - - issues = Issue.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, - ) - - -class SubIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - @method_decorator(gzip_page) - def get(self, request, slug, project_id, issue_id): - sub_issues = ( - Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ) - - state_distribution = ( - State.objects.filter(workspace__slug=slug, state_issue__parent_id=issue_id) - .annotate(state_group=F("group")) - .values("state_group") - .annotate(state_count=Count("state_group")) - .order_by("state_group") - ) - - result = { - item["state_group"]: item["state_count"] for item in state_distribution - } - - serializer = IssueLiteSerializer( - sub_issues, - many=True, - ) - return Response( - { - "sub_issues": serializer.data, - "state_distribution": result, - }, - status=status.HTTP_200_OK, - ) - - # Assign multiple sub issues - def post(self, request, slug, project_id, issue_id): - parent_issue = Issue.issue_objects.get(pk=issue_id) - sub_issue_ids = request.data.get("sub_issue_ids", []) - - if not len(sub_issue_ids): - return Response( - {"error": "Sub Issue IDs are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) - - for sub_issue in sub_issues: - sub_issue.parent = parent_issue - - _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - - updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) - - # Track the issue - _ = [ - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"parent": str(issue_id)}), - actor_id=str(request.user.id), - issue_id=str(sub_issue_id), - project_id=str(project_id), - current_instance=json.dumps({"parent": str(sub_issue_id)}), - epoch=int(timezone.now().timestamp()), - ) - for sub_issue_id in sub_issue_ids - ] - - return Response( - IssueFlatSerializer(updated_sub_issues, many=True).data, - status=status.HTTP_200_OK, - ) - - -class IssueLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - model = IssueLink - serializer_class = IssueLinkSerializer - - def get_queryset(self): - return ( - 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) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - serializer = IssueLinkSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - ) - issue_activity.delay( - type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="link.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, ) + .select_related("actor", "workspace", "issue", "project") + ).order_by(request.GET.get("order_by", "created_at")) + + if pk: + issue_activities = issue_activities.get(pk=pk) + serializer = IssueActivitySerializer(issue_activities) return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + return self.paginate( + request=request, + queryset=(issue_activities), + on_results=lambda issue_activity: IssueActivitySerializer( + issue_activity, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - issue_activity.delay( - type="link.activity.deleted", - requested_data=json.dumps({"link_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - issue_link.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class BulkCreateIssueLabelsEndpoint(BaseAPIView): - def post(self, request, slug, project_id): - label_data = request.data.get("label_data", []) - project = Project.objects.get(pk=project_id) - - labels = Label.objects.bulk_create( - [ - Label( - name=label.get("name", "Migrated"), - description=label.get("description", "Migrated Issue"), - color="#" + "%06x" % random.randint(0, 0xFFFFFF), - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for label in label_data - ], - batch_size=50, - ignore_conflicts=True, - ) - - return Response( - {"labels": LabelSerializer(labels, many=True).data}, - status=status.HTTP_201_CREATED, - ) - - -class IssueAttachmentEndpoint(BaseAPIView): - serializer_class = IssueAttachmentSerializer - permission_classes = [ - ProjectEntityPermission, - ] - model = IssueAttachment - parser_classes = (MultiPartParser, FormParser) - - def post(self, request, slug, project_id, issue_id): - serializer = IssueAttachmentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) - issue_activity.delay( - type="attachment.activity.created", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - serializer.data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) - issue_attachment.asset.delete(save=False) - issue_attachment.delete() - issue_activity.delay( - type="attachment.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - - return Response(status=status.HTTP_204_NO_CONTENT) - - def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class IssueArchiveViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(archived_at__isnull=False) - .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") - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - show_sub_issues = request.GET.get("show_sub_issues", "true") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issue_queryset = ( - issue_queryset - if show_sub_issues == "true" - else issue_queryset.filter(parent__isnull=True) - ) - - issues = IssueLiteSerializer(issue_queryset, many=True).data - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - return Response(group_results(issues, group_by), status=status.HTTP_200_OK) - - return Response(issues, status=status.HTTP_200_OK) - - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, - project_id=project_id, - archived_at__isnull=False, - pk=pk, - ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def unarchive(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, - project_id=project_id, - archived_at__isnull=False, - pk=pk, - ) - issue_activity.delay( - type="issue.activity.updated", - requested_data=json.dumps({"archived_at": None}), - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ), - epoch=int(timezone.now().timestamp()), - ) - issue.archived_at = None - issue.save() - - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - -class IssueSubscriberViewSet(BaseViewSet): - serializer_class = IssueSubscriberSerializer - model = IssueSubscriber - - permission_classes = [ - ProjectEntityPermission, - ] - - def get_permissions(self): - if self.action in ["subscribe", "unsubscribe", "subscription_status"]: - self.permission_classes = [ - ProjectLitePermission, - ] - else: - self.permission_classes = [ - ProjectEntityPermission, - ] - - return super(IssueSubscriberViewSet, self).get_permissions() - - 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 ( - 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) - .order_by("-created_at") - .distinct() - ) - - def list(self, request, slug, project_id, issue_id): - members = ( - ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - subscriber=OuterRef("member"), - ) - ) - ) - .select_related("member") - ) - serializer = ProjectMemberLiteSerializer(members, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, issue_id, subscriber_id): - issue_subscriber = IssueSubscriber.objects.get( - project=project_id, - subscriber=subscriber_id, - workspace__slug=slug, - issue=issue_id, - ) - issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - - def subscribe(self, request, slug, project_id, issue_id): - if IssueSubscriber.objects.filter( - issue_id=issue_id, - subscriber=request.user, - workspace__slug=slug, - project=project_id, - ).exists(): - return Response( - {"message": "User already subscribed to the issue."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - subscriber = IssueSubscriber.objects.create( - issue_id=issue_id, - subscriber_id=request.user.id, - project_id=project_id, - ) - serializer = IssueSubscriberSerializer(subscriber) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def unsubscribe(self, request, slug, project_id, issue_id): - issue_subscriber = IssueSubscriber.objects.get( - project=project_id, - subscriber=request.user, - workspace__slug=slug, - issue=issue_id, - ) - issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) - - def subscription_status(self, request, slug, project_id, issue_id): - issue_subscriber = IssueSubscriber.objects.filter( - issue=issue_id, - subscriber=request.user, - workspace__slug=slug, - project=project_id, - ).exists() - return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) - - -class IssueReactionViewSet(BaseViewSet): - serializer_class = IssueReactionSerializer - model = IssueReaction - permission_classes = [ - ProjectLitePermission, - ] - - def get_queryset(self): - return ( - 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) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, issue_id): - serializer = IssueReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - issue_id=issue_id, - project_id=project_id, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, reaction_code): - issue_reaction = IssueReaction.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(issue_reaction.id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - issue_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CommentReactionViewSet(BaseViewSet): - serializer_class = CommentReactionSerializer - model = CommentReaction - permission_classes = [ - ProjectLitePermission, - ] - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(comment_id=self.kwargs.get("comment_id")) - .filter(project__project_projectmember__member=self.request.user) - .order_by("-created_at") - .distinct() - ) - - def create(self, request, slug, project_id, comment_id): - serializer = CommentReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - actor_id=request.user.id, - comment_id=comment_id, - ) - issue_activity.delay( - type="comment_reaction.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=None, - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, comment_id, reaction_code): - comment_reaction = CommentReaction.objects.get( - workspace__slug=slug, - project_id=project_id, - comment_id=comment_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="comment_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(comment_reaction.id), - "comment_id": str(comment_id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - comment_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueCommentPublicViewSet(BaseViewSet): - serializer_class = IssueCommentSerializer - model = IssueComment - - filterset_fields = [ - "issue__id", - "workspace__id", - ] - - def get_permissions(self): - if self.action in ["list", "retrieve"]: - self.permission_classes = [ - AllowAny, - ] - else: - self.permission_classes = [ - IsAuthenticated, - ] - - return super(IssueCommentPublicViewSet, self).get_permissions() - - def get_queryset(self): - try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - if project_deploy_board.comments: - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(access="EXTERNAL") - .select_related("project") - .select_related("workspace") - .select_related("issue") - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - member_id=self.request.user.id, - ) - ) - ) - .distinct() - ).order_by("created_at") - return IssueComment.objects.none() - except ProjectDeployBoard.DoesNotExist: - return IssueComment.objects.none() - - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.comments: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = IssueCommentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - actor=request.user, - access="EXTERNAL", - ) - issue_activity.delay( - type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - if not ProjectMember.objects.filter( - project_id=project_id, - member=request.user, - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, - member=request.user, - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.comments: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, actor=request.user - ) - serializer = IssueCommentSerializer(comment, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="comment.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=json.dumps( - IssueCommentSerializer(comment).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.comments: - return Response( - {"error": "Comments are not enabled for this project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user - ) - issue_activity.delay( - type="comment.activity.deleted", - requested_data=json.dumps({"comment_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=json.dumps( - IssueCommentSerializer(comment).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - comment.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueReactionPublicViewSet(BaseViewSet): - serializer_class = IssueReactionSerializer - model = IssueReaction - - def get_queryset(self): - try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - if project_deploy_board.reactions: - return ( - 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")) - .order_by("-created_at") - .distinct() - ) - return IssueReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: - return IssueReaction.objects.none() - - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.reactions: - return Response( - {"error": "Reactions are not enabled for this project board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = IssueReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user - ) - if not ProjectMember.objects.filter( - project_id=project_id, - member=request.user, - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, - member=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, issue_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.reactions: - return Response( - {"error": "Reactions are not enabled for this project board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - issue_reaction = IssueReaction.objects.get( - workspace__slug=slug, - issue_id=issue_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="issue_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(issue_reaction.id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - issue_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CommentReactionPublicViewSet(BaseViewSet): - serializer_class = CommentReactionSerializer - model = CommentReaction - - def get_queryset(self): - try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - if project_deploy_board.reactions: - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(comment_id=self.kwargs.get("comment_id")) - .order_by("-created_at") - .distinct() - ) - return CommentReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: - return CommentReaction.objects.none() - - def create(self, request, slug, project_id, comment_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - if not project_deploy_board.reactions: - return Response( - {"error": "Reactions are not enabled for this board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = CommentReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, comment_id=comment_id, actor=request.user - ) - if not ProjectMember.objects.filter( - project_id=project_id, member=request.user - ).exists(): - # Add the user for workspace tracking - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, - member=request.user, - ) - issue_activity.delay( - type="comment_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, comment_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - if not project_deploy_board.reactions: - return Response( - {"error": "Reactions are not enabled for this board"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - comment_reaction = CommentReaction.objects.get( - project_id=project_id, - workspace__slug=slug, - comment_id=comment_id, - reaction=reaction_code, - actor=request.user, - ) - issue_activity.delay( - type="comment_reaction.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(comment_reaction.id), - "comment_id": str(comment_id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - comment_reaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueVotePublicViewSet(BaseViewSet): - model = IssueVote - serializer_class = IssueVoteSerializer - - def get_queryset(self): - try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - if project_deploy_board.votes: - return ( - super() - .get_queryset() - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - ) - return IssueVote.objects.none() - except ProjectDeployBoard.DoesNotExist: - return IssueVote.objects.none() - - def create(self, request, slug, project_id, issue_id): - issue_vote, _ = IssueVote.objects.get_or_create( - actor_id=request.user.id, - project_id=project_id, - issue_id=issue_id, - ) - # Add the user for workspace tracking - if not ProjectMember.objects.filter( - project_id=project_id, member=request.user - ).exists(): - _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, - member=request.user, - ) - issue_vote.vote = request.data.get("vote", 1) - issue_vote.save() - issue_activity.delay( - type="issue_vote.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - serializer = IssueVoteSerializer(issue_vote) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def destroy(self, request, slug, project_id, issue_id): - issue_vote = IssueVote.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - actor_id=request.user.id, - ) - issue_activity.delay( - type="issue_vote.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "vote": str(issue_vote.vote), - "identifier": str(issue_vote.id), - } - ), - epoch=int(timezone.now().timestamp()), - ) - issue_vote.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueRelationViewSet(BaseViewSet): - serializer_class = IssueRelationSerializer - model = IssueRelation - permission_classes = [ - ProjectEntityPermission, - ] - - 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() - ) - - def create(self, request, slug, project_id, issue_id): - related_list = request.data.get("related_list", []) - relation = request.data.get("relation", None) - project = Project.objects.get(pk=project_id) - - issue_relation = IssueRelation.objects.bulk_create( - [ - IssueRelation( - issue_id=related_issue["issue"], - related_issue_id=related_issue["related_issue"], - relation_type=related_issue["relation_type"], - project_id=project_id, - workspace_id=project.workspace_id, - created_by=request.user, - updated_by=request.user, - ) - for related_issue in related_list - ], - batch_size=10, - ignore_conflicts=True, - ) - - issue_activity.delay( - type="issue_relation.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - - if relation == "blocking": - return Response( - RelatedIssueSerializer(issue_relation, many=True).data, - status=status.HTTP_201_CREATED, - ) - else: - return Response( - IssueRelationSerializer(issue_relation, many=True).data, - status=status.HTTP_201_CREATED, - ) - - def destroy(self, request, slug, project_id, issue_id, pk): - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueRelationSerializer(issue_relation).data, - cls=DjangoJSONEncoder, - ) - issue_relation.delete() - issue_activity.delay( - type="issue_relation.activity.deleted", - requested_data=json.dumps({"related_list": None}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueRetrievePublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, slug, project_id, issue_id): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=issue_id - ) - serializer = IssuePublicSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class ProjectIssuesPublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project", "workspace", "state", "parent") - .prefetch_related("assignees", "labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .prefetch_related( - Prefetch( - "votes", - queryset=IssueVote.objects.select_related("actor"), - ) - ) - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssuePublicSerializer(issue_queryset, many=True).data - - state_group_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - - states = ( - State.objects.filter( - ~Q(name="Triage"), - workspace__slug=slug, - project_id=project_id, - ) - .annotate( - custom_order=Case( - *[ - When(group=value, then=Value(index)) - for index, value in enumerate(state_group_order) - ], - default=Value(len(state_group_order)), - output_field=IntegerField(), - ), - ) - .values("name", "group", "color", "id") - .order_by("custom_order", "sequence") - ) - - labels = Label.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("id", "name", "color", "parent") - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - issues = group_results(issues, group_by) - - return Response( - { - "issues": issues, - "states": states, - "labels": labels, - }, - status=status.HTTP_200_OK, - ) - - -class IssueDraftViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True).data - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - grouped_results = group_results(issues, group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response(issues, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save(is_draft=True) - - # Track the issue - issue_activity.delay( - type="issue_draft.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, pk): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - serializer = IssueSerializer(issue, data=request.data, partial=True) - - if serializer.is_valid(): - if request.data.get("is_draft") is not None and not request.data.get( - "is_draft" - ): - serializer.save(created_at=timezone.now(), updated_at=timezone.now()) - else: - serializer.save() - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(issue).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True - ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - issue.delete() - issue_activity.delay( - type="issue_draft.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 6c2088922..221c7f31b 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -1,73 +1,53 @@ # Python imports import json -# Django Imports +# Django imports +from django.db.models import Count, Prefetch, Q, F, Func, OuterRef from django.utils import timezone -from django.db import IntegrityError -from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page # Third party imports -from rest_framework.response import Response from rest_framework import status -from sentry_sdk import capture_exception +from rest_framework.response import Response # Module imports -from . import BaseViewSet +from .base import BaseAPIView, WebhookMixin +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Project, + Module, + ModuleLink, + Issue, + ModuleIssue, + IssueAttachment, + IssueLink, +) from plane.api.serializers import ( - ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, - ModuleLinkSerializer, - ModuleFavoriteSerializer, - IssueStateSerializer, -) -from plane.api.permissions import ProjectEntityPermission -from plane.db.models import ( - Module, - ModuleIssue, - Project, - Issue, - ModuleLink, - ModuleFavorite, - IssueLink, - IssueAttachment, + IssueSerializer, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results -from plane.utils.issue_filters import issue_filters -from plane.utils.analytics_plot import burndown_plot -class ModuleViewSet(BaseViewSet): +class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to module. + + """ + model = Module permission_classes = [ ProjectEntityPermission, ] - - def get_serializer_class(self): - return ( - ModuleWriteSerializer - if self.action in ["create", "update", "partial_update"] - else ModuleSerializer - ) + serializer_class = ModuleSerializer + webhook_event = "module" def get_queryset(self): - - subquery = ModuleFavorite.objects.filter( - user=self.request.user, - module_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) return ( - super() - .get_queryset() - .filter(project_id=self.kwargs.get("project_id")) + Module.objects.filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .annotate(is_favorite=Exists(subquery)) .select_related("project") .select_related("workspace") .select_related("lead") @@ -137,130 +117,51 @@ class ModuleViewSet(BaseViewSet): ), ) ) - .order_by("-is_favorite","-created_at") + .order_by(self.kwargs.get("order_by", "-created_at")) ) - def create(self, request, slug, project_id): + def post(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) - serializer = ModuleWriteSerializer( - data=request.data, context={"project": project} - ) - + serializer = ModuleSerializer(data=request.data, context={"project": project}) if serializer.is_valid(): serializer.save() - module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, pk): + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + serializer = ModuleSerializer(module, 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) - def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - - assignee_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, + def get(self, request, slug, project_id, pk=None): + if pk: + queryset = self.get_queryset().get(pk=pk) + data = ModuleSerializer( + queryset, + fields=self.fields, + expand=self.expand, + ).data + return Response( + data, + status=status.HTTP_200_OK, ) - .annotate(first_name=F("assignees__first_name")) - .annotate(last_name=F("assignees__last_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate( - total_issues=Count( - "assignee_id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - completed_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "assignee_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("first_name", "last_name") + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda modules: ModuleSerializer( + modules, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - label_distribution = ( - Issue.objects.filter( - issue_module__module_id=pk, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), - ) - .annotate( - completed_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "label_id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - data = ModuleSerializer(queryset).data - data["distribution"] = { - "assignees": assignee_distribution, - "labels": label_distribution, - "completion_chart": {}, - } - - if queryset.start_date and queryset.target_date: - data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, module_id=pk - ) - - return Response( - data, - status=status.HTTP_200_OK, - ) - - def destroy(self, request, slug, project_id, pk): + def delete(self, request, slug, project_id, pk): module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) module_issues = list( ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) @@ -275,7 +176,7 @@ class ModuleViewSet(BaseViewSet): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -284,24 +185,25 @@ class ModuleViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class ModuleIssueViewSet(BaseViewSet): +class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to module issues. + + """ + serializer_class = ModuleIssueSerializer model = ModuleIssue - - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] + webhook_event = "module_issue" + bulk = True permission_classes = [ ProjectEntityPermission, ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( + return ( + ModuleIssue.objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -317,15 +219,12 @@ class ModuleIssueViewSet(BaseViewSet): .select_related("issue", "issue__state", "issue__project") .prefetch_related("issue__assignees", "issue__labels") .prefetch_related("module__members") + .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) - @method_decorator(gzip_page) - def list(self, request, slug, project_id, module_id): + def get(self, request, slug, project_id, module_id): order_by = request.GET.get("order_by", "created_at") - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_module__module_id=module_id) .annotate( @@ -344,7 +243,6 @@ class ModuleIssueViewSet(BaseViewSet): .prefetch_related("assignees") .prefetch_related("labels") .order_by(order_by) - .filter(**filters) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -358,26 +256,18 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) ) - issues_data = IssueStateSerializer(issues, many=True).data - - if sub_group_by and sub_group_by == group_by: - return Response( - {"error": "Group by and sub group by cannot be same"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if group_by: - grouped_results = group_results(issues_data, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response( - issues_data, status=status.HTTP_200_OK + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: IssueSerializer( + issues, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - def create(self, request, slug, project_id, module_id): + def post(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not len(issues): return Response( @@ -387,6 +277,10 @@ class ModuleIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=module_id ) + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issues + ).values_list("id", flat=True) + module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) update_module_issue_activity = [] @@ -438,7 +332,7 @@ class ModuleIssueViewSet(BaseViewSet): # Capture Issue Activity issue_activity.delay( type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), + requested_data=json.dumps({"modules_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -458,9 +352,9 @@ class ModuleIssueViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - def destroy(self, request, slug, project_id, module_id, pk): + def delete(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk + workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id ) module_issue.delete() issue_activity.delay( @@ -472,67 +366,9 @@ class ModuleIssueViewSet(BaseViewSet): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=str(issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ModuleLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - model = ModuleLink - serializer_class = ModuleLinkSerializer - - 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 ( - 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) - .order_by("-created_at") - .distinct() - ) - - -class ModuleFavoriteViewSet(BaseViewSet): - serializer_class = ModuleFavoriteSerializer - model = ModuleFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("module") - ) - - def create(self, request, slug, project_id): - serializer = ModuleFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, module_id): - module_favorite = ModuleFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - module_id=module_id, - ) - module_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py deleted file mode 100644 index ca0927a51..000000000 --- a/apiserver/plane/api/views/page.py +++ /dev/null @@ -1,255 +0,0 @@ -# Python imports -from datetime import timedelta, date - -# Django imports -from django.db.models import Exists, OuterRef, Q, Prefetch -from django.utils import timezone - -# Third party imports -from rest_framework import status -from rest_framework.response import Response -from sentry_sdk import capture_exception - -# Module imports -from .base import BaseViewSet, BaseAPIView -from plane.api.permissions import ProjectEntityPermission -from plane.db.models import ( - Page, - PageBlock, - PageFavorite, - Issue, - IssueAssignee, - IssueActivity, -) -from plane.api.serializers import ( - PageSerializer, - PageBlockSerializer, - PageFavoriteSerializer, - IssueLiteSerializer, -) - - -class PageViewSet(BaseViewSet): - serializer_class = PageSerializer - model = Page - permission_classes = [ - ProjectEntityPermission, - ] - search_fields = [ - "name", - ] - - def get_queryset(self): - subquery = PageFavorite.objects.filter( - user=self.request.user, - page_id=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - ) - 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(Q(owned_by=self.request.user) | Q(access=0)) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) - .order_by(self.request.GET.get("order_by", "-created_at")) - .prefetch_related("labels") - .order_by("name", "-is_favorite") - .prefetch_related( - Prefetch( - "blocks", - queryset=PageBlock.objects.select_related( - "page", "issue", "workspace", "project" - ), - ) - ) - .distinct() - ) - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), owned_by=self.request.user - ) - - def create(self, request, slug, project_id): - serializer = PageSerializer( - data=request.data, - context={"project_id": project_id, "owned_by_id": request.user.id}, - ) - - 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) - - def partial_update(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) - # Only update access if the page owner is the requesting user - if ( - page.access != request.data.get("access", page.access) - and page.owned_by_id != request.user.id - ): - return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - serializer = PageSerializer(page, data=request.data, 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) - - def list(self, request, slug, project_id): - queryset = self.get_queryset() - page_view = request.GET.get("page_view", False) - - if not page_view: - return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST) - - # All Pages - if page_view == "all": - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) - - # Recent pages - if page_view == "recent": - current_time = date.today() - day_before = current_time - timedelta(days=1) - todays_pages = queryset.filter(updated_at__date=date.today()) - yesterdays_pages = queryset.filter(updated_at__date=day_before) - earlier_this_week = queryset.filter( updated_at__date__range=( - (timezone.now() - timedelta(days=7)), - (timezone.now() - timedelta(days=2)), - )) - return Response( - { - "today": PageSerializer(todays_pages, many=True).data, - "yesterday": PageSerializer(yesterdays_pages, many=True).data, - "earlier_this_week": PageSerializer(earlier_this_week, many=True).data, - }, - status=status.HTTP_200_OK, - ) - - # Favorite Pages - if page_view == "favorite": - queryset = queryset.filter(is_favorite=True) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) - - # My pages - if page_view == "created_by_me": - queryset = queryset.filter(owned_by=request.user) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) - - # Created by other Pages - if page_view == "created_by_other": - queryset = queryset.filter(~Q(owned_by=request.user), access=0) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) - - return Response({"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST) - - -class PageBlockViewSet(BaseViewSet): - serializer_class = PageBlockSerializer - model = PageBlock - permission_classes = [ - ProjectEntityPermission, - ] - - 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(page_id=self.kwargs.get("page_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("page") - .select_related("issue") - .order_by("sort_order") - .distinct() - ) - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - page_id=self.kwargs.get("page_id"), - ) - - -class PageFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - - serializer_class = PageFavoriteSerializer - model = PageFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("page", "page__owned_by") - ) - - def create(self, request, slug, project_id): - serializer = PageFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, page_id): - page_favorite = PageFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - page_id=page_id, - ) - page_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - -class CreateIssueFromPageBlockEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - def post(self, request, slug, project_id, page_id, page_block_id): - page_block = PageBlock.objects.get( - pk=page_block_id, - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - ) - issue = Issue.objects.create( - name=page_block.name, - project_id=project_id, - description=page_block.description, - description_html=page_block.description_html, - description_stripped=page_block.description_stripped, - ) - _ = IssueAssignee.objects.create( - issue=issue, assignee=request.user, project_id=project_id - ) - - _ = IssueActivity.objects.create( - issue=issue, - actor=request.user, - project_id=project_id, - comment=f"created the issue from {page_block.name} block", - verb="created", - ) - - page_block.issue = issue - page_block.save() - - return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 37e491e83..e8dc9f5a9 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,121 +1,63 @@ -# Python imports -import jwt -import boto3 -from datetime import datetime - # Django imports -from django.core.exceptions import ValidationError from django.db import IntegrityError -from django.db.models import ( - Prefetch, - Q, - Exists, - OuterRef, - F, - Func, - Subquery, -) -from django.core.validators import validate_email -from django.conf import settings +from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch -# Third Party imports -from rest_framework.response import Response +# Third party imports from rest_framework import status -from rest_framework import serializers -from rest_framework.permissions import AllowAny -from sentry_sdk import capture_exception +from rest_framework.response import Response +from rest_framework.serializers import ValidationError # Module imports -from .base import BaseViewSet, BaseAPIView -from plane.api.serializers import ( - ProjectSerializer, - ProjectListSerializer, - ProjectMemberSerializer, - ProjectDetailSerializer, - ProjectMemberInviteSerializer, - ProjectFavoriteSerializer, - ProjectDeployBoardSerializer, - ProjectMemberAdminSerializer, -) - -from plane.api.permissions import ( - ProjectBasePermission, - ProjectEntityPermission, - ProjectMemberPermission, - ProjectLitePermission, -) - from plane.db.models import ( - Project, - ProjectMember, Workspace, - ProjectMemberInvite, - User, - WorkspaceMember, - State, - TeamMember, + Project, ProjectFavorite, - ProjectIdentifier, - Module, - Cycle, - CycleFavorite, - ModuleFavorite, - PageFavorite, - IssueViewFavorite, - Page, - IssueAssignee, - ModuleMember, - Inbox, + ProjectMember, ProjectDeployBoard, + State, + Cycle, + Module, IssueProperty, + Inbox, ) - -from plane.bgtasks.project_invitation_task import project_invitation +from plane.app.permissions import ProjectBasePermission +from plane.api.serializers import ProjectSerializer +from .base import BaseAPIView, WebhookMixin -class ProjectViewSet(BaseViewSet): +class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): + """Project Endpoints to create, update, list, retrieve and delete endpoint""" + serializer_class = ProjectSerializer model = Project + webhook_event = "project" permission_classes = [ ProjectBasePermission, ] - def get_serializer_class(self, *args, **kwargs): - if self.action in ["update", "partial_update"]: - return ProjectSerializer - return ProjectDetailSerializer - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) .select_related( "workspace", "workspace__owner", "default_assignee", "project_lead" ) - .annotate( - is_favorite=Exists( - ProjectFavorite.objects.filter( - user=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - ) - ) .annotate( is_member=Exists( ProjectMember.objects.filter( member=self.request.user, project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), + is_active=True, ) ) ) .annotate( total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), member__is_bot=False + project_id=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -137,6 +79,7 @@ class ProjectViewSet(BaseViewSet): member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), member_id=self.request.user.id, + is_active=True, ).values("role") ) .annotate( @@ -147,49 +90,46 @@ class ProjectViewSet(BaseViewSet): ) ) ) + .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) - def list(self, request, slug): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - - sort_order_query = ProjectMember.objects.filter( - member=request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ).values("sort_order") - projects = ( - self.get_queryset() - .annotate(sort_order=Subquery(sort_order_query)) - .prefetch_related( - Prefetch( - "project_projectmember", - queryset=ProjectMember.objects.filter( - workspace__slug=slug, - ).select_related("member"), + def get(self, request, slug, project_id=None): + if project_id is None: + sort_order_query = ProjectMember.objects.filter( + member=request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + projects = ( + self.get_queryset() + .annotate(sort_order=Subquery(sort_order_query)) + .prefetch_related( + Prefetch( + "project_projectmember", + queryset=ProjectMember.objects.filter( + workspace__slug=slug, + is_active=True, + ).select_related("member"), + ) ) + .order_by(request.GET.get("order_by", "sort_order")) ) - .order_by("sort_order", "name") - ) - if request.GET.get("per_page", False) and request.GET.get("cursor", False): return self.paginate( request=request, queryset=(projects), - on_results=lambda projects: ProjectListSerializer( - projects, many=True + on_results=lambda projects: ProjectSerializer( + projects, many=True, fields=self.fields, expand=self.expand, ).data, ) + project = self.get_queryset().get(workspace__slug=slug, pk=project_id) + serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) + return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - ProjectListSerializer( - projects, many=True, fields=fields if fields else None - ).data - ) - - def create(self, request, slug): + def post(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) - serializer = ProjectSerializer( data={**request.data}, context={"workspace_id": workspace.id} ) @@ -272,7 +212,7 @@ class ProjectViewSet(BaseViewSet): ) project = self.get_queryset().filter(pk=serializer.data["id"]).first() - serializer = ProjectListSerializer(project) + serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( serializer.errors, @@ -288,17 +228,16 @@ class ProjectViewSet(BaseViewSet): return Response( {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND ) - except serializers.ValidationError as e: + except ValidationError as e: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) - def partial_update(self, request, slug, pk=None): + def patch(self, request, slug, project_id=None): try: workspace = Workspace.objects.get(slug=slug) - - project = Project.objects.get(pk=pk) + project = Project.objects.get(pk=project_id) serializer = ProjectSerializer( project, @@ -319,15 +258,14 @@ class ProjectViewSet(BaseViewSet): name="Triage", group="backlog", description="Default state for managing all Inbox Issues", - project_id=pk, + project_id=project_id, color="#ff7700", ) project = self.get_queryset().filter(pk=serializer.data["id"]).first() - serializer = ProjectListSerializer(project) + serializer = ProjectSerializer(project) 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( @@ -338,710 +276,13 @@ class ProjectViewSet(BaseViewSet): return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) - except serializers.ValidationError as e: + except ValidationError as e: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) - -class InviteProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def post(self, request, slug, project_id): - 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, - member__is_bot=False, - ).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 - ) - - _ = IssueProperty.objects.create(user=user, project_id=project_id) - - return Response( - ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK - ) - - -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", "workspace__owner", "project") - ) - - def create(self, request): - 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, - created_by=request.user, - ) - for invitation in project_invitations - ] - ) - - IssueProperty.objects.bulk_create( - [ - ProjectMember( - project=invitation.project, - workspace=invitation.project.workspace, - user=request.user, - created_by=request.user, - ) - for invitation in project_invitations - ] - ) - - # Delete joined project invites - project_invitations.delete() - - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectMemberViewSet(BaseViewSet): - serializer_class = ProjectMemberAdminSerializer - model = ProjectMember - permission_classes = [ - ProjectMemberPermission, - ] - - search_fields = [ - "member__display_name", - "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")) - .filter(member__is_bot=False) - .select_related("project") - .select_related("member") - .select_related("workspace", "workspace__owner") - ) - - def create(self, request, slug, project_id): - members = request.data.get("members", []) - - # get the project - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - if not len(members): - return Response( - {"error": "Atleast one member is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - bulk_project_members = [] - bulk_issue_props = [] - - project_members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], - ) - .values("member_id", "sort_order") - .order_by("sort_order") - ) - - for member in members: - sort_order = [ - project_member.get("sort_order") - for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) - ] - bulk_project_members.append( - ProjectMember( - member_id=member.get("member_id"), - role=member.get("role", 10), - project_id=project_id, - workspace_id=project.workspace_id, - sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, - ) - ) - bulk_issue_props.append( - IssueProperty( - user_id=member.get("member_id"), - project_id=project_id, - workspace_id=project.workspace_id, - ) - ) - - project_members = ProjectMember.objects.bulk_create( - bulk_project_members, - batch_size=10, - ignore_conflicts=True, - ) - - _ = IssueProperty.objects.bulk_create( - bulk_issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def list(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - member=request.user, workspace__slug=slug, project_id=project_id - ) - - project_members = ProjectMember.objects.filter( - project_id=project_id, - workspace__slug=slug, - member__is_bot=False, - ).select_related("project", "member", "workspace") - - if project_member.role > 10: - serializer = ProjectMemberAdminSerializer(project_members, many=True) - else: - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, pk): - project_member = ProjectMember.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) - if request.user.id == project_member.member_id: - return Response( - {"error": "You cannot update your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Check while updating user roles - requested_project_member = ProjectMember.objects.get( - project_id=project_id, workspace__slug=slug, member=request.user - ) - if ( - "role" in request.data - and int(request.data.get("role", project_member.role)) - > requested_project_member.role - ): - return Response( - {"error": "You cannot update a role that is higher than your own role"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = ProjectMemberSerializer( - project_member, data=request.data, 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) - - def destroy(self, request, slug, project_id, pk): - project_member = ProjectMember.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - # check requesting user role - requesting_project_member = ProjectMember.objects.get( - workspace__slug=slug, member=request.user, project_id=project_id - ) - if requesting_project_member.role < project_member.role: - return Response( - {"error": "You cannot remove a user having role higher than yourself"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Remove all favorites - ProjectFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - CycleFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - ModuleFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - PageFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - IssueViewFavorite.objects.filter( - workspace__slug=slug, project_id=project_id, user=project_member.member - ).delete() - # Also remove issue from issue assigned - IssueAssignee.objects.filter( - workspace__slug=slug, - project_id=project_id, - assignee=project_member.member, - ).delete() - - # Remove if module member - ModuleMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member=project_member.member, - ).delete() - # Delete owned Pages - Page.objects.filter( - workspace__slug=slug, - project_id=project_id, - owned_by=project_member.member, - ).delete() - project_member.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class AddTeamToProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def post(self, request, slug, project_id): - 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 = [] - issue_props = [] - for member in team_members: - project_members.append( - ProjectMember( - project_id=project_id, - member_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - issue_props.append( - IssueProperty( - project_id=project_id, - user_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - - ProjectMember.objects.bulk_create( - project_members, batch_size=10, ignore_conflicts=True - ) - - _ = IssueProperty.objects.bulk_create( - issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -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") - .select_related("workspace", "workspace__owner") - ) - - -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") - .select_related("workspace", "workspace__owner") - ) - - -class ProjectIdentifierEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def get(self, request, slug): - 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, - ) - - def delete(self, request, slug): - 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, - ) - - -class ProjectJoinEndpoint(BaseAPIView): - def post(self, request, slug): - 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, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - IssueProperty.objects.bulk_create( - [ - IssueProperty( - project_id=project_id, - user=request.user, - workspace=workspace, - created_by=request.user, - ) - for project_id in project_ids - ], - ignore_conflicts=True, - ) - - return Response( - {"message": "Projects joined successfully"}, - status=status.HTTP_201_CREATED, - ) - - -class ProjectUserViewsEndpoint(BaseAPIView): - def post(self, request, slug, project_id): - 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 - preferences = project_member.preferences - sort_order = project_member.sort_order - - project_member.view_props = request.data.get("view_props", view_props) - project_member.default_props = request.data.get("default_props", default_props) - project_member.preferences = request.data.get("preferences", preferences) - project_member.sort_order = request.data.get("sort_order", sort_order) - - project_member.save() - - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectMemberUserEndpoint(BaseAPIView): - def get(self, request, slug, project_id): - 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) - - -class ProjectFavoritesViewSet(BaseViewSet): - serializer_class = ProjectFavoriteSerializer - model = ProjectFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related( - "project", "project__project_lead", "project__default_assignee" - ) - .select_related("workspace", "workspace__owner") - ) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def create(self, request, slug): - serializer = ProjectFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id): - project_favorite = ProjectFavorite.objects.get( - project=project_id, user=request.user, workspace__slug=slug - ) - project_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectDeployBoardViewSet(BaseViewSet): - permission_classes = [ - ProjectMemberPermission, - ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .select_related("project") - ) - - def create(self, request, slug, project_id): - comments = request.data.get("comments", False) - reactions = request.data.get("reactions", False) - inbox = request.data.get("inbox", None) - votes = request.data.get("votes", False) - views = request.data.get( - "views", - { - "list": True, - "kanban": True, - "calendar": True, - "gantt": True, - "spreadsheet": True, - }, - ) - - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( - anchor=f"{slug}/{project_id}", - project_id=project_id, - ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions - project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views - - project_deploy_board.save() - - serializer = ProjectDeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - serializer = ProjectDeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, slug): - projects = ( - Project.objects.filter(workspace__slug=slug) - .annotate( - is_public=Exists( - ProjectDeployBoard.objects.filter( - workspace__slug=slug, project_id=OuterRef("pk") - ) - ) - ) - .filter(is_public=True) - ).values( - "id", - "identifier", - "name", - "description", - "emoji", - "icon_prop", - "cover_image", - ) - - return Response(projects, status=status.HTTP_200_OK) - - -class LeaveProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] - def delete(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - workspace__slug=slug, - member=request.user, - project_id=project_id, - ) - - # Only Admin case - if ( - project_member.role == 20 - and ProjectMember.objects.filter( - workspace__slug=slug, - role=20, - project_id=project_id, - ).count() - == 1 - ): - return Response( - { - "error": "You cannot leave the project since you are the only admin of the project you should delete the project" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Delete the member from workspace - project_member.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectPublicCoverImagesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request): - files = [] - s3 = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - params = { - "Bucket": settings.AWS_S3_BUCKET_NAME, - "Prefix": "static/project-cover/", - } - - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) - - return Response(files, status=status.HTTP_200_OK) + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index dbb6e1d71..3d2861778 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -7,30 +7,24 @@ from django.db.models import Q # 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 .base import BaseAPIView from plane.api.serializers import StateSerializer -from plane.api.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission from plane.db.models import State, Issue -class StateViewSet(BaseViewSet): +class StateAPIEndpoint(BaseAPIView): 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")) + return ( + State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) .filter(~Q(name="Triage")) @@ -39,49 +33,41 @@ class StateViewSet(BaseViewSet): .distinct() ) - def create(self, request, slug, project_id): - serializer = StateSerializer(data=request.data) + def post(self, request, slug, project_id): + serializer = StateSerializer(data=request.data, context={"project_id": project_id}) if serializer.is_valid(): serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def list(self, request, slug, project_id): - states = StateSerializer(self.get_queryset(), many=True).data - grouped = request.GET.get("grouped", False) - if grouped == "true": - state_dict = {} - for key, value in groupby( - sorted(states, key=lambda state: state["group"]), - lambda state: state.get("group"), - ): - state_dict[str(key)] = list(value) - return Response(state_dict, status=status.HTTP_200_OK) - return Response(states, status=status.HTTP_200_OK) + def get(self, request, slug, project_id, state_id=None): + if state_id: + serializer = StateSerializer(self.get_queryset().get(pk=state_id)) + return Response(serializer.data, status=status.HTTP_200_OK) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda states: StateSerializer( + states, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) - def mark_as_default(self, request, slug, project_id, pk): - # Select all the states which are marked as default - _ = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True - ).update(default=False) - _ = State.objects.filter( - workspace__slug=slug, project_id=project_id, pk=pk - ).update(default=True) - return Response(status=status.HTTP_204_NO_CONTENT) - - def destroy(self, request, slug, project_id, pk): + def delete(self, request, slug, project_id, state_id): state = State.objects.get( ~Q(name="Triage"), - pk=pk, + pk=state_id, project_id=project_id, workspace__slug=slug, ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=False) + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) # Check for any issues in the state - issue_exist = Issue.issue_objects.filter(state=pk).exists() + issue_exist = Issue.issue_objects.filter(state=state_id).exists() if issue_exist: return Response( @@ -91,3 +77,11 @@ class StateViewSet(BaseViewSet): state.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + def patch(self, request, slug, project_id, state_id=None): + state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) + serializer = StateSerializer(state, data=request.data, 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) \ No newline at end of file diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py deleted file mode 100644 index 2e40565b4..000000000 --- a/apiserver/plane/api/views/user.py +++ /dev/null @@ -1,73 +0,0 @@ -# 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, - IssueActivitySerializer, - UserMeSerializer, - UserMeSettingsSerializer, -) - -from plane.api.views.base import BaseViewSet, BaseAPIView -from plane.db.models import ( - User, - Workspace, - WorkspaceMemberInvite, - Issue, - IssueActivity, -) -from plane.utils.paginator import BasePaginator - - -class UserEndpoint(BaseViewSet): - serializer_class = UserSerializer - model = User - - def get_object(self): - return self.request.user - - def retrieve(self, request): - serialized_data = UserMeSerializer(request.user).data - return Response( - serialized_data, - status=status.HTTP_200_OK, - ) - - def retrieve_user_settings(self, request): - serialized_data = UserMeSettingsSerializer(request.user).data - return Response(serialized_data, status=status.HTTP_200_OK) - - -class UpdateUserOnBoardedEndpoint(BaseAPIView): - def patch(self, request): - 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) - - -class UpdateUserTourCompletedEndpoint(BaseAPIView): - def patch(self, request): - user = User.objects.get(pk=request.user.id) - user.is_tour_completed = request.data.get("is_tour_completed", False) - user.save() - return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) - - -class UserActivityEndpoint(BaseAPIView, BasePaginator): - def get(self, request, slug): - queryset = IssueActivity.objects.filter( - actor=request.user, workspace__slug=slug - ).select_related("actor", "workspace", "issue", "project") - - return self.paginate( - request=request, - queryset=queryset, - on_results=lambda issue_activities: IssueActivitySerializer( - issue_activities, many=True - ).data, - ) diff --git a/apiserver/plane/app/__init__.py b/apiserver/plane/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/app/apps.py b/apiserver/plane/app/apps.py new file mode 100644 index 000000000..e3277fc4d --- /dev/null +++ b/apiserver/plane/app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AppApiConfig(AppConfig): + name = "plane.app" diff --git a/apiserver/plane/app/middleware/__init__.py b/apiserver/plane/app/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/app/middleware/api_authentication.py b/apiserver/plane/app/middleware/api_authentication.py new file mode 100644 index 000000000..ddabb4132 --- /dev/null +++ b/apiserver/plane/app/middleware/api_authentication.py @@ -0,0 +1,47 @@ +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + +# Module imports +from plane.db.models import APIToken + + +class APIKeyAuthentication(authentication.BaseAuthentication): + """ + Authentication with an API Key + """ + + www_authenticate_realm = "api" + media_type = "application/json" + auth_header_name = "X-Api-Key" + + def get_api_token(self, request): + return request.headers.get(self.auth_header_name) + + def validate_api_token(self, token): + try: + api_token = APIToken.objects.get( + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + token=token, + is_active=True, + ) + except APIToken.DoesNotExist: + raise AuthenticationFailed("Given API token is not valid") + + # save api token last used + api_token.last_used = timezone.now() + api_token.save(update_fields=["last_used"]) + return (api_token.user, api_token.token) + + def authenticate(self, request): + token = self.get_api_token(request=request) + if not token: + return None + + # Validate the API token + user, token = self.validate_api_token(token) + return user, token diff --git a/apiserver/plane/app/permissions/__init__.py b/apiserver/plane/app/permissions/__init__.py new file mode 100644 index 000000000..2298f3442 --- /dev/null +++ b/apiserver/plane/app/permissions/__init__.py @@ -0,0 +1,17 @@ + +from .workspace import ( + WorkSpaceBasePermission, + WorkspaceOwnerPermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, + WorkspaceUserPermission, +) +from .project import ( + ProjectBasePermission, + ProjectEntityPermission, + ProjectMemberPermission, + ProjectLitePermission, +) + + diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/app/permissions/project.py similarity index 87% rename from apiserver/plane/api/permissions/project.py rename to apiserver/plane/app/permissions/project.py index 4f907dbd6..80775cbf6 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/app/permissions/project.py @@ -13,14 +13,15 @@ Guest = 5 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 WorkspaceMember.objects.filter( - workspace__slug=view.workspace_slug, member=request.user + workspace__slug=view.workspace_slug, + member=request.user, + is_active=True, ).exists() ## Only workspace owners or admins can create the projects @@ -29,6 +30,7 @@ class ProjectBasePermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, role__in=[Admin, Member], + is_active=True, ).exists() ## Only Project Admins can update project attributes @@ -37,19 +39,21 @@ class ProjectBasePermission(BasePermission): member=request.user, role=Admin, project_id=view.project_id, + is_active=True, ).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 ProjectMember.objects.filter( - workspace__slug=view.workspace_slug, member=request.user + workspace__slug=view.workspace_slug, + member=request.user, + is_active=True, ).exists() ## Only workspace owners or admins can create the projects if request.method == "POST": @@ -57,6 +61,7 @@ class ProjectMemberPermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, role__in=[Admin, Member], + is_active=True, ).exists() ## Only Project Admins can update project attributes @@ -65,12 +70,12 @@ class ProjectMemberPermission(BasePermission): member=request.user, role__in=[Admin, Member], project_id=view.project_id, + is_active=True, ).exists() class ProjectEntityPermission(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: return False @@ -80,6 +85,7 @@ class ProjectEntityPermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, project_id=view.project_id, + is_active=True, ).exists() ## Only project members or admins can create and edit the project attributes @@ -88,17 +94,18 @@ class ProjectEntityPermission(BasePermission): member=request.user, role__in=[Admin, Member], project_id=view.project_id, + is_active=True, ).exists() class ProjectLitePermission(BasePermission): - def has_permission(self, request, view): if request.user.is_anonymous: return False - + return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, project_id=view.project_id, + is_active=True, ).exists() diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/app/permissions/workspace.py similarity index 68% rename from apiserver/plane/api/permissions/workspace.py rename to apiserver/plane/app/permissions/workspace.py index 66e836614..f73ae1f67 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/app/permissions/workspace.py @@ -32,15 +32,31 @@ class WorkSpaceBasePermission(BasePermission): member=request.user, workspace__slug=view.workspace_slug, role__in=[Owner, Admin], + is_active=True, ).exists() # allow only owner to delete the workspace if request.method == "DELETE": return WorkspaceMember.objects.filter( - member=request.user, workspace__slug=view.workspace_slug, role=Owner + member=request.user, + workspace__slug=view.workspace_slug, + role=Owner, + is_active=True, ).exists() +class WorkspaceOwnerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=Owner, + ).exists() + + class WorkSpaceAdminPermission(BasePermission): def has_permission(self, request, view): if request.user.is_anonymous: @@ -50,6 +66,7 @@ class WorkSpaceAdminPermission(BasePermission): member=request.user, workspace__slug=view.workspace_slug, role__in=[Owner, Admin], + is_active=True, ).exists() @@ -63,12 +80,14 @@ class WorkspaceEntityPermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, + is_active=True, ).exists() return WorkspaceMember.objects.filter( member=request.user, workspace__slug=view.workspace_slug, role__in=[Owner, Admin], + is_active=True, ).exists() @@ -78,5 +97,19 @@ class WorkspaceViewerPermission(BasePermission): return False return WorkspaceMember.objects.filter( - member=request.user, workspace__slug=view.workspace_slug, role__gte=10 + member=request.user, + workspace__slug=view.workspace_slug, + is_active=True, + ).exists() + + +class WorkspaceUserPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + is_active=True, ).exists() diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py new file mode 100644 index 000000000..c406453b7 --- /dev/null +++ b/apiserver/plane/app/serializers/__init__.py @@ -0,0 +1,104 @@ +from .base import BaseSerializer +from .user import ( + UserSerializer, + UserLiteSerializer, + ChangePasswordSerializer, + ResetPasswordSerializer, + UserAdminLiteSerializer, + UserMeSerializer, + UserMeSettingsSerializer, +) +from .workspace import ( + WorkSpaceSerializer, + WorkSpaceMemberSerializer, + TeamSerializer, + WorkSpaceMemberInviteSerializer, + WorkspaceLiteSerializer, + WorkspaceThemeSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, +) +from .project import ( + ProjectSerializer, + ProjectListSerializer, + ProjectDetailSerializer, + ProjectMemberSerializer, + ProjectMemberInviteSerializer, + ProjectIdentifierSerializer, + ProjectFavoriteSerializer, + ProjectLiteSerializer, + ProjectMemberLiteSerializer, + ProjectDeployBoardSerializer, + ProjectMemberAdminSerializer, + ProjectPublicMemberSerializer, +) +from .state import StateSerializer, StateLiteSerializer +from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer +from .cycle import ( + CycleSerializer, + CycleIssueSerializer, + CycleFavoriteSerializer, + CycleWriteSerializer, +) +from .asset import FileAssetSerializer +from .issue import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssuePropertySerializer, + IssueAssigneeSerializer, + LabelSerializer, + IssueSerializer, + IssueFlatSerializer, + IssueStateSerializer, + IssueLinkSerializer, + IssueLiteSerializer, + IssueAttachmentSerializer, + IssueSubscriberSerializer, + IssueReactionSerializer, + CommentReactionSerializer, + IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, + IssuePublicSerializer, +) + +from .module import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleFavoriteSerializer, +) + +from .api import APITokenSerializer, APITokenReadSerializer + +from .integration import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, + GithubIssueSyncSerializer, + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, + SlackProjectSyncSerializer, +) + +from .importer import ImporterSerializer + +from .page import PageSerializer, PageLogSerializer, SubPageSerializer, PageFavoriteSerializer + +from .estimate import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, +) + +from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer + +from .analytic import AnalyticViewSerializer + +from .notification import NotificationSerializer + +from .exporter import ExporterHistorySerializer + +from .webhook import WebhookSerializer, WebhookLogSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/analytic.py b/apiserver/plane/app/serializers/analytic.py similarity index 100% rename from apiserver/plane/api/serializers/analytic.py rename to apiserver/plane/app/serializers/analytic.py diff --git a/apiserver/plane/app/serializers/api.py b/apiserver/plane/app/serializers/api.py new file mode 100644 index 000000000..08bb747d9 --- /dev/null +++ b/apiserver/plane/app/serializers/api.py @@ -0,0 +1,31 @@ +from .base import BaseSerializer +from plane.db.models import APIToken, APIActivityLog + + +class APITokenSerializer(BaseSerializer): + + class Meta: + model = APIToken + fields = "__all__" + read_only_fields = [ + "token", + "expired_at", + "created_at", + "updated_at", + "workspace", + "user", + ] + + +class APITokenReadSerializer(BaseSerializer): + + class Meta: + model = APIToken + exclude = ('token',) + + +class APIActivityLogSerializer(BaseSerializer): + + class Meta: + model = APIActivityLog + fields = "__all__" diff --git a/apiserver/plane/api/serializers/asset.py b/apiserver/plane/app/serializers/asset.py similarity index 100% rename from apiserver/plane/api/serializers/asset.py rename to apiserver/plane/app/serializers/asset.py diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py new file mode 100644 index 000000000..89c9725d9 --- /dev/null +++ b/apiserver/plane/app/serializers/base.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) + +class DynamicBaseSerializer(BaseSerializer): + + def __init__(self, *args, **kwargs): + # If 'fields' is provided in the arguments, remove it and store it separately. + # This is done so as not to pass this custom argument up to the superclass. + fields = kwargs.pop("fields", None) + + # Call the initialization of the superclass. + super().__init__(*args, **kwargs) + + # If 'fields' was provided, filter the fields of the serializer accordingly. + if fields is not None: + self.fields = self._filter_fields(fields) + + def _filter_fields(self, fields): + """ + Adjust the serializer's fields based on the provided 'fields' list. + + :param fields: List or dictionary specifying which fields to include in the serializer. + :return: The updated fields for the serializer. + """ + # Check each field_name in the provided fields. + for field_name in fields: + # If the field is a dictionary (indicating nested fields), + # loop through its keys and values. + if isinstance(field_name, dict): + for key, value in field_name.items(): + # If the value of this nested field is a list, + # perform a recursive filter on it. + if isinstance(value, list): + self._filter_fields(self.fields[key], value) + + # Create a list to store allowed fields. + allowed = [] + for item in fields: + # If the item is a string, it directly represents a field's name. + if isinstance(item, str): + allowed.append(item) + # If the item is a dictionary, it represents a nested field. + # Add the key of this dictionary to the allowed list. + elif isinstance(item, dict): + allowed.append(list(item.keys())[0]) + + # Convert the current serializer's fields and the allowed fields to sets. + existing = set(self.fields) + allowed = set(allowed) + + # Remove fields from the serializer that aren't in the 'allowed' list. + for field_name in (existing - allowed): + self.fields.pop(field_name) + + return self.fields diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py new file mode 100644 index 000000000..104a3dd06 --- /dev/null +++ b/apiserver/plane/app/serializers/cycle.py @@ -0,0 +1,107 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .issue import IssueStateSerializer +from .workspace import WorkspaceLiteSerializer +from .project import ProjectLiteSerializer +from plane.db.models import Cycle, CycleIssue, CycleFavorite + + +class CycleWriteSerializer(BaseSerializer): + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + return data + + class Meta: + model = Cycle + fields = "__all__" + + +class CycleSerializer(BaseSerializer): + owned_by = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + assignees = serializers.SerializerMethodField(read_only=True) + total_estimates = serializers.IntegerField(read_only=True) + completed_estimates = serializers.IntegerField(read_only=True) + started_estimates = serializers.IntegerField(read_only=True) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + return data + + def get_assignees(self, obj): + members = [ + { + "avatar": assignee.avatar, + "display_name": assignee.display_name, + "id": assignee.id, + } + for issue_cycle in obj.issue_cycle.prefetch_related( + "issue__assignees" + ).all() + for assignee in issue_cycle.issue.assignees.all() + ] + # Use a set comprehension to return only the unique objects + unique_objects = {frozenset(item.items()) for item in members} + + # Convert the set back to a list of dictionaries + unique_list = [dict(item) for item in unique_objects] + + return unique_list + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] + + +class CycleIssueSerializer(BaseSerializer): + issue_detail = IssueStateSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "cycle", + ] + + +class CycleFavoriteSerializer(BaseSerializer): + cycle_detail = CycleSerializer(source="cycle", read_only=True) + + class Meta: + model = CycleFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py similarity index 94% rename from apiserver/plane/api/serializers/estimate.py rename to apiserver/plane/app/serializers/estimate.py index 3cb0e4713..4a1cda779 100644 --- a/apiserver/plane/api/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,7 +2,7 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.api.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer +from plane.app.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer class EstimateSerializer(BaseSerializer): diff --git a/apiserver/plane/api/serializers/exporter.py b/apiserver/plane/app/serializers/exporter.py similarity index 100% rename from apiserver/plane/api/serializers/exporter.py rename to apiserver/plane/app/serializers/exporter.py diff --git a/apiserver/plane/api/serializers/importer.py b/apiserver/plane/app/serializers/importer.py similarity index 100% rename from apiserver/plane/api/serializers/importer.py rename to apiserver/plane/app/serializers/importer.py diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/inbox.py new file mode 100644 index 000000000..f52a90660 --- /dev/null +++ b/apiserver/plane/app/serializers/inbox.py @@ -0,0 +1,57 @@ +# Third party frameworks +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer +from .project import ProjectLiteSerializer +from .state import StateLiteSerializer +from .user import UserLiteSerializer +from plane.db.models import Inbox, InboxIssue, Issue + + +class InboxSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + pending_issue_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Inbox + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = InboxIssue + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueLiteSerializer(BaseSerializer): + class Meta: + model = InboxIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +class IssueStateInboxSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/app/serializers/integration/__init__.py similarity index 100% rename from apiserver/plane/api/serializers/integration/__init__.py rename to apiserver/plane/app/serializers/integration/__init__.py diff --git a/apiserver/plane/api/serializers/integration/base.py b/apiserver/plane/app/serializers/integration/base.py similarity index 90% rename from apiserver/plane/api/serializers/integration/base.py rename to apiserver/plane/app/serializers/integration/base.py index 10ebd4620..6f6543b9e 100644 --- a/apiserver/plane/api/serializers/integration/base.py +++ b/apiserver/plane/app/serializers/integration/base.py @@ -1,5 +1,5 @@ # Module imports -from plane.api.serializers import BaseSerializer +from plane.app.serializers import BaseSerializer from plane.db.models import Integration, WorkspaceIntegration diff --git a/apiserver/plane/api/serializers/integration/github.py b/apiserver/plane/app/serializers/integration/github.py similarity index 95% rename from apiserver/plane/api/serializers/integration/github.py rename to apiserver/plane/app/serializers/integration/github.py index 8352dcee1..850bccf1b 100644 --- a/apiserver/plane/api/serializers/integration/github.py +++ b/apiserver/plane/app/serializers/integration/github.py @@ -1,5 +1,5 @@ # Module imports -from plane.api.serializers import BaseSerializer +from plane.app.serializers import BaseSerializer from plane.db.models import ( GithubIssueSync, GithubRepository, diff --git a/apiserver/plane/api/serializers/integration/slack.py b/apiserver/plane/app/serializers/integration/slack.py similarity index 86% rename from apiserver/plane/api/serializers/integration/slack.py rename to apiserver/plane/app/serializers/integration/slack.py index f535a64de..9c461c5b9 100644 --- a/apiserver/plane/api/serializers/integration/slack.py +++ b/apiserver/plane/app/serializers/integration/slack.py @@ -1,5 +1,5 @@ # Module imports -from plane.api.serializers import BaseSerializer +from plane.app.serializers import BaseSerializer from plane.db.models import SlackProjectSync diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py new file mode 100644 index 000000000..b13d03e35 --- /dev/null +++ b/apiserver/plane/app/serializers/issue.py @@ -0,0 +1,616 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from .user import UserLiteSerializer +from .state import StateSerializer, StateLiteSerializer +from .project import ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueActivity, + IssueComment, + IssueProperty, + IssueAssignee, + IssueSubscriber, + IssueLabel, + Label, + CycleIssue, + Cycle, + Module, + ModuleIssue, + IssueLink, + IssueAttachment, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, +) + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = [ + "id", + "project_detail", + "name", + "sequence_id", + ] + read_only_fields = fields + + +##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 = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] + data['labels'] = [str(label.id) for label in instance.labels.all()] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + else: + # Then assign it to default assignee + if default_assignee_id is not None: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if labels is not None and len(labels): + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + 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_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class IssueActivitySerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + class Meta: + model = IssueActivity + fields = "__all__" + + + +class IssuePropertySerializer(BaseSerializer): + class Meta: + model = IssueProperty + fields = "__all__" + read_only_fields = [ + "user", + "workspace", + "project", + ] + + +class LabelSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Label + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + +class IssueLabelSerializer(BaseSerializer): + + class Meta: + model = IssueLabel + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class IssueRelationSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + + class Meta: + model = IssueRelation + fields = [ + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", + ] + +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + + class Meta: + model = IssueRelation + fields = [ + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", + ] + + +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 IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) + + +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = IssueAttachment + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueReactionSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "actor", + ] + + +class CommentReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = CommentReaction + fields = [ + "id", + "reaction", + "comment", + "actor_detail", + ] + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor"] + + +class IssueVoteSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + read_only_fields = fields + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = [ + "id", + "sequence_id", + "name", + "state_detail", + "project_detail", + ] + + +# Issue Serializer with state details +class IssueStateSerializer(DynamicBaseSerializer): + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Issue + fields = "__all__" + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateSerializer(read_only=True, source="state") + parent_detail = IssueStateFlatSerializer(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) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) + issue_cycle = IssueCycleDetailSerializer(read_only=True) + issue_module = IssueModuleDetailSerializer(read_only=True) + issue_link = IssueLinkSerializer(read_only=True, many=True) + issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueLiteSerializer(DynamicBaseSerializer): + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + cycle_id = serializers.UUIDField(read_only=True) + module_id = serializers.UUIDField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "start_date", + "target_date", + "completed_at", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + votes = IssueVoteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + ] + read_only_fields = fields + + + +class IssueSubscriberSerializer(BaseSerializer): + class Meta: + model = IssueSubscriber + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + ] diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py new file mode 100644 index 000000000..48f773b0f --- /dev/null +++ b/apiserver/plane/app/serializers/module.py @@ -0,0 +1,198 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .project import ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer + +from plane.db.models import ( + User, + Module, + ModuleMember, + ModuleIssue, + ModuleLink, + ModuleFavorite, +) + + +class ModuleWriteSerializer(BaseSerializer): + members = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + project_detail = ProjectLiteSerializer(source="project", read_only=True) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data['members'] = [str(member.id) for member in instance.members.all()] + return data + + def validate(self, data): + if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): + raise serializers.ValidationError("Start date cannot exceed target date") + return data + + def create(self, validated_data): + members = validated_data.pop("members", 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", None) + + if members is not None: + ModuleMember.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 = ProjectLiteSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + +class ModuleLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = ModuleLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "module", + ] + + # Validation if url already exists + def create(self, validated_data): + if ModuleLink.objects.filter( + url=validated_data.get("url"), module_id=validated_data.get("module_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return ModuleLink.objects.create(**validated_data) + + +class ModuleSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + lead_detail = UserLiteSerializer(read_only=True, source="lead") + members_detail = UserLiteSerializer(read_only=True, many=True, source="members") + link_module = ModuleLinkSerializer(read_only=True, many=True) + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleFavoriteSerializer(BaseSerializer): + module_detail = ModuleFlatSerializer(source="module", read_only=True) + + class Meta: + model = ModuleFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/app/serializers/notification.py similarity index 100% rename from apiserver/plane/api/serializers/notification.py rename to apiserver/plane/app/serializers/notification.py diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/app/serializers/page.py similarity index 81% rename from apiserver/plane/api/serializers/page.py rename to apiserver/plane/app/serializers/page.py index abdf958cb..ff152627a 100644 --- a/apiserver/plane/api/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -6,28 +6,7 @@ from .base import BaseSerializer from .issue import IssueFlatSerializer, LabelLiteSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label - - -class PageBlockSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - - class Meta: - model = PageBlock - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "page", - ] - -class PageBlockLiteSerializer(BaseSerializer): - - class Meta: - model = PageBlock - fields = "__all__" +from plane.db.models import Page, PageLog, PageFavorite, PageLabel, Label, Issue, Module class PageSerializer(BaseSerializer): @@ -38,7 +17,6 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) - blocks = PageBlockLiteSerializer(read_only=True, many=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) @@ -102,6 +80,41 @@ class PageSerializer(BaseSerializer): return super().update(instance, validated_data) +class SubPageSerializer(BaseSerializer): + entity_details = serializers.SerializerMethodField() + + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + def get_entity_details(self, obj): + entity_name = obj.entity_name + if entity_name == 'forward_link' or entity_name == 'back_link': + try: + page = Page.objects.get(pk=obj.entity_identifier) + return PageSerializer(page).data + except Page.DoesNotExist: + return None + return None + + +class PageLogSerializer(BaseSerializer): + + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + class PageFavoriteSerializer(BaseSerializer): page_detail = PageSerializer(source="page", read_only=True) diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py new file mode 100644 index 000000000..aef715e33 --- /dev/null +++ b/apiserver/plane/app/serializers/project.py @@ -0,0 +1,220 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from plane.app.serializers.workspace import WorkspaceLiteSerializer +from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer +from plane.db.models import ( + Project, + ProjectMember, + ProjectMemberInvite, + ProjectIdentifier, + ProjectFavorite, + ProjectDeployBoard, + ProjectPublicMember, +) + + +class ProjectSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + 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) + project_identifier = ProjectIdentifier.objects.filter( + project=project + ).first() + if project_identifier is not None: + project_identifier.name = identifier + project_identifier.save() + 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 ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "icon_prop", + "emoji", + "description", + ] + read_only_fields = fields + + +class ProjectListSerializer(DynamicBaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + total_members = serializers.IntegerField(read_only=True) + total_cycles = serializers.IntegerField(read_only=True) + total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + is_deployed = serializers.BooleanField(read_only=True) + members = serializers.SerializerMethodField() + + def get_members(self, obj): + project_members = getattr(obj, "members_list", None) + if project_members is not None: + # Filter members by the project ID + return [ + { + "id": member.id, + "member_id": member.member_id, + "member__display_name": member.member.display_name, + "member__avatar": member.member.avatar, + } + for member in project_members + ] + return [] + + class Meta: + model = Project + fields = "__all__" + + +class ProjectDetailSerializer(BaseSerializer): + # workspace = WorkSpaceSerializer(read_only=True) + default_assignee = UserLiteSerializer(read_only=True) + project_lead = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) + total_members = serializers.IntegerField(read_only=True) + total_cycles = serializers.IntegerField(read_only=True) + total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + is_deployed = serializers.BooleanField(read_only=True) + + class Meta: + model = Project + fields = "__all__" + + +class ProjectMemberSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberAdminSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserAdminLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberInviteSerializer(BaseSerializer): + project = ProjectLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) + + class Meta: + model = ProjectMemberInvite + fields = "__all__" + + +class ProjectIdentifierSerializer(BaseSerializer): + class Meta: + model = ProjectIdentifier + fields = "__all__" + + +class ProjectFavoriteSerializer(BaseSerializer): + class Meta: + model = ProjectFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "user", + ] + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta: + model = ProjectMember + fields = ["member", "id", "is_subscribed"] + read_only_fields = fields + + +class ProjectDeployBoardSerializer(BaseSerializer): + project_details = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + class Meta: + model = ProjectDeployBoard + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "anchor", + ] + + +class ProjectPublicMemberSerializer(BaseSerializer): + class Meta: + model = ProjectPublicMember + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "member", + ] \ No newline at end of file diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py new file mode 100644 index 000000000..323254f26 --- /dev/null +++ b/apiserver/plane/app/serializers/state.py @@ -0,0 +1,28 @@ +# 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", + ] + + +class StateLiteSerializer(BaseSerializer): + class Meta: + model = State + fields = [ + "id", + "name", + "color", + "group", + ] + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py new file mode 100644 index 000000000..1b94758e8 --- /dev/null +++ b/apiserver/plane/app/serializers/user.py @@ -0,0 +1,193 @@ +# Third party imports +from rest_framework import serializers + +# Module import +from .base import BaseSerializer +from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.license.models import InstanceAdmin, Instance + + +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", + "is_bot", + "is_password_autoset", + "is_email_verified", + ] + extra_kwargs = {"password": {"write_only": True}} + + # If the user has already filled first name or last name then he is onboarded + def get_is_onboarded(self, obj): + return bool(obj.first_name) or bool(obj.last_name) + + +class UserMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "cover_image", + "date_joined", + "display_name", + "email", + "first_name", + "last_name", + "is_active", + "is_bot", + "is_email_verified", + "is_managed", + "is_onboarded", + "is_tour_completed", + "mobile_number", + "role", + "onboarding_step", + "user_timezone", + "username", + "theme", + "last_workspace_id", + "use_case", + "is_password_autoset", + "is_email_verified", + ] + read_only_fields = fields + + +class UserMeSettingsSerializer(BaseSerializer): + workspace = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + "id", + "email", + "workspace", + ] + read_only_fields = fields + + def get_workspace(self, obj): + workspace_invites = WorkspaceMemberInvite.objects.filter( + email=obj.email + ).count() + if ( + obj.last_workspace_id is not None + and Workspace.objects.filter( + pk=obj.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).exists() + ): + workspace = Workspace.objects.filter( + pk=obj.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).first() + return { + "last_workspace_id": obj.last_workspace_id, + "last_workspace_slug": workspace.slug if workspace is not None else "", + "fallback_workspace_id": obj.last_workspace_id, + "fallback_workspace_slug": workspace.slug + if workspace is not None + else "", + "invites": workspace_invites, + } + else: + fallback_workspace = ( + Workspace.objects.filter( + workspace_member__member_id=obj.id, workspace_member__is_active=True + ) + .order_by("created_at") + .first() + ) + return { + "last_workspace_id": None, + "last_workspace_slug": None, + "fallback_workspace_id": fallback_workspace.id + if fallback_workspace is not None + else None, + "fallback_workspace_slug": fallback_workspace.slug + if fallback_workspace is not None + else None, + "invites": workspace_invites, + } + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "is_bot", + "display_name", + ] + read_only_fields = [ + "id", + "is_bot", + ] + + +class UserAdminLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "is_bot", + "display_name", + "email", + ] + read_only_fields = [ + "id", + "is_bot", + ] + + +class ChangePasswordSerializer(serializers.Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) + + def validate(self, data): + if data.get("old_password") == data.get("new_password"): + raise serializers.ValidationError( + {"error": "New password cannot be same as old password."} + ) + + if data.get("new_password") != data.get("confirm_password"): + raise serializers.ValidationError( + {"error": "Confirm password should be same as the new password."} + ) + + return data + + +class ResetPasswordSerializer(serializers.Serializer): + """ + Serializer for password change endpoint. + """ + new_password = serializers.CharField(required=True, min_length=8) diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/app/serializers/view.py similarity index 100% rename from apiserver/plane/api/serializers/view.py rename to apiserver/plane/app/serializers/view.py diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py new file mode 100644 index 000000000..961466d28 --- /dev/null +++ b/apiserver/plane/app/serializers/webhook.py @@ -0,0 +1,106 @@ +# Python imports +import urllib +import socket +import ipaddress +from urllib.parse import urlparse + +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import DynamicBaseSerializer +from plane.db.models import Webhook, WebhookLog +from plane.db.models.webhook import validate_domain, validate_schema + +class WebhookSerializer(DynamicBaseSerializer): + url = serializers.URLField(validators=[validate_schema, validate_domain]) + + def create(self, validated_data): + url = validated_data.get("url", None) + + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_private or ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get('request') + disallowed_domains = ['plane.so',] # Add your disallowed domains here + if request: + request_host = request.get_host().split(':')[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return Webhook.objects.create(**validated_data) + + def update(self, instance, validated_data): + url = validated_data.get("url", None) + if url: + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_private or ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get('request') + disallowed_domains = ['plane.so',] # Add your disallowed domains here + if request: + request_host = request.get_host().split(':')[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return super().update(instance, validated_data) + + class Meta: + model = Webhook + fields = "__all__" + read_only_fields = [ + "workspace", + "secret_key", + ] + + +class WebhookLogSerializer(DynamicBaseSerializer): + + class Meta: + model = WebhookLog + fields = "__all__" + read_only_fields = [ + "workspace", + "webhook" + ] + diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py new file mode 100644 index 000000000..f0ad4b4ab --- /dev/null +++ b/apiserver/plane/app/serializers/workspace.py @@ -0,0 +1,163 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer, UserAdminLiteSerializer + +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + Team, + TeamMember, + WorkspaceMemberInvite, + WorkspaceTheme, +) + + +class WorkSpaceSerializer(BaseSerializer): + owner = UserLiteSerializer(read_only=True) + total_members = serializers.IntegerField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + + def validated(self, data): + if data.get("slug") in [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + "password", + ]: + raise serializers.ValidationError({"slug": "Slug is not valid"}) + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + ] + +class WorkspaceLiteSerializer(BaseSerializer): + class Meta: + model = Workspace + fields = [ + "name", + "slug", + "id", + ] + read_only_fields = fields + + + +class WorkSpaceMemberSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkspaceMemberMeSerializer(BaseSerializer): + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkspaceMemberAdminSerializer(BaseSerializer): + member = UserAdminLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) + + class Meta: + model = WorkspaceMember + fields = "__all__" + + +class WorkSpaceMemberInviteSerializer(BaseSerializer): + workspace = WorkSpaceSerializer(read_only=True) + total_members = serializers.IntegerField(read_only=True) + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = WorkspaceMemberInvite + fields = "__all__" + read_only_fields = [ + "id", + "email", + "token", + "workspace", + "message", + "responded_at", + "created_at", + "updated_at", + ] + + +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 + 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) + return super().update(instance, validated_data) + + +class WorkspaceThemeSerializer(BaseSerializer): + class Meta: + model = WorkspaceTheme + fields = "__all__" + read_only_fields = [ + "workspace", + "actor", + ] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py new file mode 100644 index 000000000..d8334ed57 --- /dev/null +++ b/apiserver/plane/app/urls/__init__.py @@ -0,0 +1,48 @@ +from .analytic import urlpatterns as analytic_urls +from .asset import urlpatterns as asset_urls +from .authentication import urlpatterns as authentication_urls +from .config import urlpatterns as configuration_urls +from .cycle import urlpatterns as cycle_urls +from .estimate import urlpatterns as estimate_urls +from .external import urlpatterns as external_urls +from .importer import urlpatterns as importer_urls +from .inbox import urlpatterns as inbox_urls +from .integration import urlpatterns as integration_urls +from .issue import urlpatterns as issue_urls +from .module import urlpatterns as module_urls +from .notification import urlpatterns as notification_urls +from .page import urlpatterns as page_urls +from .project import urlpatterns as project_urls +from .search import urlpatterns as search_urls +from .state import urlpatterns as state_urls +from .user import urlpatterns as user_urls +from .views import urlpatterns as view_urls +from .workspace import urlpatterns as workspace_urls +from .api import urlpatterns as api_urls +from .webhook import urlpatterns as webhook_urls + + +urlpatterns = [ + *analytic_urls, + *asset_urls, + *authentication_urls, + *configuration_urls, + *cycle_urls, + *estimate_urls, + *external_urls, + *importer_urls, + *inbox_urls, + *integration_urls, + *issue_urls, + *module_urls, + *notification_urls, + *page_urls, + *project_urls, + *search_urls, + *state_urls, + *user_urls, + *view_urls, + *workspace_urls, + *api_urls, + *webhook_urls, +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/analytic.py b/apiserver/plane/app/urls/analytic.py similarity index 97% rename from apiserver/plane/api/urls/analytic.py rename to apiserver/plane/app/urls/analytic.py index cb6155e32..668268350 100644 --- a/apiserver/plane/api/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( AnalyticsEndpoint, AnalyticViewViewset, SavedAnalyticEndpoint, diff --git a/apiserver/plane/app/urls/api.py b/apiserver/plane/app/urls/api.py new file mode 100644 index 000000000..b77ea8530 --- /dev/null +++ b/apiserver/plane/app/urls/api.py @@ -0,0 +1,17 @@ +from django.urls import path +from plane.app.views import ApiTokenEndpoint + +urlpatterns = [ + # API Tokens + path( + "workspaces//api-tokens/", + ApiTokenEndpoint.as_view(), + name="api-tokens", + ), + path( + "workspaces//api-tokens//", + ApiTokenEndpoint.as_view(), + name="api-tokens", + ), + ## End API Tokens +] diff --git a/apiserver/plane/api/urls/asset.py b/apiserver/plane/app/urls/asset.py similarity index 68% rename from apiserver/plane/api/urls/asset.py rename to apiserver/plane/app/urls/asset.py index b6ae9f42c..2d84b93e0 100644 --- a/apiserver/plane/api/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -1,9 +1,10 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( FileAssetEndpoint, UserAssetsEndpoint, + FileAssetViewSet, ) @@ -28,4 +29,13 @@ urlpatterns = [ UserAssetsEndpoint.as_view(), name="user-file-assets", ), + path( + "workspaces/file-assets///restore/", + FileAssetViewSet.as_view( + { + "post": "restore", + } + ), + name="file-assets-restore", + ), ] diff --git a/apiserver/plane/api/urls/authentication.py b/apiserver/plane/app/urls/authentication.py similarity index 71% rename from apiserver/plane/api/urls/authentication.py rename to apiserver/plane/app/urls/authentication.py index 44b7000ea..39986f791 100644 --- a/apiserver/plane/api/urls/authentication.py +++ b/apiserver/plane/app/urls/authentication.py @@ -3,20 +3,18 @@ from django.urls import path from rest_framework_simplejwt.views import TokenRefreshView -from plane.api.views import ( +from plane.app.views import ( # Authentication - SignUpEndpoint, SignInEndpoint, SignOutEndpoint, + MagicGenerateEndpoint, MagicSignInEndpoint, - MagicSignInGenerateEndpoint, OauthEndpoint, + EmailCheckEndpoint, ## End Authentication # Auth Extended ForgotPasswordEndpoint, - VerifyEmailEndpoint, ResetPasswordEndpoint, - RequestEmailVerificationEndpoint, ChangePasswordEndpoint, ## End Auth Extender # API Tokens @@ -27,24 +25,15 @@ from plane.api.views import ( urlpatterns = [ # Social Auth + path("email-check/", EmailCheckEndpoint.as_view(), name="email"), path("social-auth/", OauthEndpoint.as_view(), name="oauth"), # Auth - path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), 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" - ), + # magic sign in + path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - # 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( "users/me/change-password/", diff --git a/apiserver/plane/api/urls/config.py b/apiserver/plane/app/urls/config.py similarity index 75% rename from apiserver/plane/api/urls/config.py rename to apiserver/plane/app/urls/config.py index 321a56200..12beb63aa 100644 --- a/apiserver/plane/api/urls/config.py +++ b/apiserver/plane/app/urls/config.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ConfigurationEndpoint +from plane.app.views import ConfigurationEndpoint urlpatterns = [ path( diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py new file mode 100644 index 000000000..46e6a5e84 --- /dev/null +++ b/apiserver/plane/app/urls/cycle.py @@ -0,0 +1,87 @@ +from django.urls import path + + +from plane.app.views import ( + CycleViewSet, + CycleIssueViewSet, + CycleDateCheckEndpoint, + CycleFavoriteViewSet, + TransferCycleIssueEndpoint, +) + + +urlpatterns = [ + 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-issue-cycle", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-cycle", + ), + path( + "workspaces//projects//cycles/date-check/", + CycleDateCheckEndpoint.as_view(), + name="project-cycle-date", + ), + path( + "workspaces//projects//user-favorite-cycles/", + CycleFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//user-favorite-cycles//", + CycleFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//cycles//transfer-issues/", + TransferCycleIssueEndpoint.as_view(), + name="transfer-issues", + ), +] diff --git a/apiserver/plane/api/urls/estimate.py b/apiserver/plane/app/urls/estimate.py similarity index 96% rename from apiserver/plane/api/urls/estimate.py rename to apiserver/plane/app/urls/estimate.py index 89363e849..d8571ff0c 100644 --- a/apiserver/plane/api/urls/estimate.py +++ b/apiserver/plane/app/urls/estimate.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) diff --git a/apiserver/plane/api/urls/external.py b/apiserver/plane/app/urls/external.py similarity index 74% rename from apiserver/plane/api/urls/external.py rename to apiserver/plane/app/urls/external.py index c22289035..774e6fb7c 100644 --- a/apiserver/plane/api/urls/external.py +++ b/apiserver/plane/app/urls/external.py @@ -1,9 +1,9 @@ from django.urls import path -from plane.api.views import UnsplashEndpoint -from plane.api.views import ReleaseNotesEndpoint -from plane.api.views import GPTIntegrationEndpoint +from plane.app.views import UnsplashEndpoint +from plane.app.views import ReleaseNotesEndpoint +from plane.app.views import GPTIntegrationEndpoint urlpatterns = [ diff --git a/apiserver/plane/api/urls/importer.py b/apiserver/plane/app/urls/importer.py similarity index 96% rename from apiserver/plane/api/urls/importer.py rename to apiserver/plane/app/urls/importer.py index c0a9aa5b5..f3a018d78 100644 --- a/apiserver/plane/api/urls/importer.py +++ b/apiserver/plane/app/urls/importer.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( ServiceIssueImportSummaryEndpoint, ImportServiceEndpoint, UpdateServiceImportStatusEndpoint, diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py new file mode 100644 index 000000000..16ea40b21 --- /dev/null +++ b/apiserver/plane/app/urls/inbox.py @@ -0,0 +1,53 @@ +from django.urls import path + + +from plane.app.views import ( + InboxViewSet, + InboxIssueViewSet, +) + + +urlpatterns = [ + path( + "workspaces//projects//inboxes/", + InboxViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//", + InboxViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//inbox-issues/", + InboxIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox-issue", + ), + path( + "workspaces//projects//inboxes//inbox-issues//", + InboxIssueViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox-issue", + ), +] diff --git a/apiserver/plane/api/urls/integration.py b/apiserver/plane/app/urls/integration.py similarity index 99% rename from apiserver/plane/api/urls/integration.py rename to apiserver/plane/app/urls/integration.py index dd431b6c8..cf3f82d5a 100644 --- a/apiserver/plane/api/urls/integration.py +++ b/apiserver/plane/app/urls/integration.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( IntegrationViewSet, WorkspaceIntegrationViewSet, GithubRepositoriesEndpoint, diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py new file mode 100644 index 000000000..971fbc395 --- /dev/null +++ b/apiserver/plane/app/urls/issue.py @@ -0,0 +1,315 @@ +from django.urls import path + + +from plane.app.views import ( + IssueViewSet, + LabelViewSet, + BulkCreateIssueLabelsEndpoint, + BulkDeleteIssuesEndpoint, + BulkImportIssuesEndpoint, + UserWorkSpaceIssues, + SubIssuesEndpoint, + IssueLinkViewSet, + IssueAttachmentEndpoint, + ExportIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + IssueSubscriberViewSet, + IssueReactionViewSet, + CommentReactionViewSet, + IssueUserDisplayPropertyEndpoint, + IssueArchiveViewSet, + IssueRelationViewSet, + IssueDraftViewSet, +) + + +urlpatterns = [ + 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//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-create-labels/", + BulkCreateIssueLabelsEndpoint.as_view(), + name="project-bulk-labels", + ), + path( + "workspaces//projects//bulk-delete-issues/", + BulkDeleteIssuesEndpoint.as_view(), + name="project-issues-bulk", + ), + path( + "workspaces//projects//bulk-import-issues//", + BulkImportIssuesEndpoint.as_view(), + name="project-issues-bulk", + ), + path( + "workspaces//my-issues/", + UserWorkSpaceIssues.as_view(), + name="workspace-issues", + ), + path( + "workspaces//projects//issues//sub-issues/", + SubIssuesEndpoint.as_view(), + name="sub-issues", + ), + path( + "workspaces//projects//issues//issue-links/", + IssueLinkViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-links", + ), + path( + "workspaces//projects//issues//issue-links//", + IssueLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-links", + ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentEndpoint.as_view(), + name="project-issue-attachments", + ), + path( + "workspaces//export-issues/", + ExportIssuesEndpoint.as_view(), + name="export-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 + # Issue Subscribers + path( + "workspaces//projects//issues//issue-subscribers/", + IssueSubscriberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//issue-subscribers//", + IssueSubscriberViewSet.as_view({"delete": "destroy"}), + name="project-issue-subscribers", + ), + path( + "workspaces//projects//issues//subscribe/", + IssueSubscriberViewSet.as_view( + { + "get": "subscription_status", + "post": "subscribe", + "delete": "unsubscribe", + } + ), + name="project-issue-subscribers", + ), + ## End Issue Subscribers + # Issue Reactions + path( + "workspaces//projects//issues//reactions/", + IssueReactionViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-reactions", + ), + path( + "workspaces//projects//issues//reactions//", + IssueReactionViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project-issue-reactions", + ), + ## End Issue Reactions + # Comment Reactions + path( + "workspaces//projects//comments//reactions/", + CommentReactionViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-comment-reactions", + ), + path( + "workspaces//projects//comments//reactions//", + CommentReactionViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project-issue-comment-reactions", + ), + ## End Comment Reactions + ## IssueProperty + path( + "workspaces//projects//issue-display-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", + ), + ## IssueProperty End + ## Issue Archives + path( + "workspaces//projects//archived-issues/", + IssueArchiveViewSet.as_view( + { + "get": "list", + } + ), + name="project-issue-archive", + ), + path( + "workspaces//projects//archived-issues//", + IssueArchiveViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project-issue-archive", + ), + path( + "workspaces//projects//unarchive//", + IssueArchiveViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-issue-archive", + ), + ## End Issue Archives + ## Issue Relation + path( + "workspaces//projects//issues//issue-relation/", + IssueRelationViewSet.as_view( + { + "post": "create", + } + ), + name="issue-relation", + ), + path( + "workspaces//projects//issues//issue-relation//", + IssueRelationViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-relation", + ), + ## End Issue Relation + ## Issue Drafts + path( + "workspaces//projects//issue-drafts/", + IssueDraftViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-draft", + ), + path( + "workspaces//projects//issue-drafts//", + IssueDraftViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-draft", + ), +] diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py new file mode 100644 index 000000000..5507b3a37 --- /dev/null +++ b/apiserver/plane/app/urls/module.py @@ -0,0 +1,104 @@ +from django.urls import path + + +from plane.app.views import ( + ModuleViewSet, + ModuleIssueViewSet, + ModuleLinkViewSet, + ModuleFavoriteViewSet, + BulkImportModulesEndpoint, +) + + +urlpatterns = [ + 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", + ), + path( + "workspaces//projects//modules//module-links/", + ModuleLinkViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-module-links", + ), + path( + "workspaces//projects//modules//module-links//", + ModuleLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-module-links", + ), + path( + "workspaces//projects//user-favorite-modules/", + ModuleFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-module", + ), + path( + "workspaces//projects//user-favorite-modules//", + ModuleFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-module", + ), + path( + "workspaces//projects//bulk-import-modules//", + BulkImportModulesEndpoint.as_view(), + name="bulk-modules-create", + ), +] diff --git a/apiserver/plane/api/urls/notification.py b/apiserver/plane/app/urls/notification.py similarity index 98% rename from apiserver/plane/api/urls/notification.py rename to apiserver/plane/app/urls/notification.py index 5e1936d01..0c96e5f15 100644 --- a/apiserver/plane/api/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py new file mode 100644 index 000000000..58cec2cd4 --- /dev/null +++ b/apiserver/plane/app/urls/page.py @@ -0,0 +1,133 @@ +from django.urls import path + + +from plane.app.views import ( + PageViewSet, + PageFavoriteViewSet, + PageLogEndpoint, + SubPagesEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//user-favorite-pages/", + PageFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//user-favorite-pages//", + PageFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//pages/", + PageViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//archive/", + PageViewSet.as_view( + { + "post": "archive", + } + ), + name="project-page-archive", + ), + path( + "workspaces//projects//pages//unarchive/", + PageViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-page-unarchive", + ), + path( + "workspaces//projects//archived-pages/", + PageViewSet.as_view( + { + "get": "archive_list", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//lock/", + PageViewSet.as_view( + { + "post": "lock", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//unlock/", + PageViewSet.as_view( + { + "post": "unlock", + } + ), + ), + path( + "workspaces//projects//pages//transactions/", + PageLogEndpoint.as_view(), + name="page-transactions", + ), + path( + "workspaces//projects//pages//transactions//", + PageLogEndpoint.as_view(), + name="page-transactions", + ), + path( + "workspaces//projects//pages//sub-pages/", + SubPagesEndpoint.as_view(), + name="sub-page", + ), +] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py new file mode 100644 index 000000000..39456a830 --- /dev/null +++ b/apiserver/plane/app/urls/project.py @@ -0,0 +1,178 @@ +from django.urls import path + +from plane.app.views import ( + ProjectViewSet, + ProjectInvitationsViewset, + ProjectMemberViewSet, + ProjectMemberUserEndpoint, + ProjectJoinEndpoint, + AddTeamToProjectEndpoint, + ProjectUserViewsEndpoint, + ProjectIdentifierEndpoint, + ProjectFavoritesViewSet, + UserProjectInvitationsViewset, + ProjectPublicCoverImagesEndpoint, + ProjectDeployBoardViewSet, + UserProjectRolesEndpoint, +) + + +urlpatterns = [ + 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//invitations/", + ProjectInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="project-member-invite", + ), + path( + "workspaces//projects//invitations//", + ProjectInvitationsViewset.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project-member-invite", + ), + path( + "users/me/workspaces//projects/invitations/", + UserProjectInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="user-project-invitations", + ), + path( + "users/me/workspaces//project-roles/", + UserProjectRolesEndpoint.as_view(), + name="user-project-roles", + ), + path( + "workspaces//projects//join//", + ProjectJoinEndpoint.as_view(), + name="project-join", + ), + path( + "workspaces//projects//members/", + ProjectMemberViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-member", + ), + path( + "workspaces//projects//members//", + ProjectMemberViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-member", + ), + path( + "workspaces//projects//members/leave/", + ProjectMemberViewSet.as_view( + { + "post": "leave", + } + ), + name="project-member", + ), + path( + "workspaces//projects//team-invite/", + AddTeamToProjectEndpoint.as_view(), + name="projects", + ), + path( + "workspaces//projects//project-views/", + ProjectUserViewsEndpoint.as_view(), + name="project-view", + ), + path( + "workspaces//projects//project-members/me/", + ProjectMemberUserEndpoint.as_view(), + name="project-member-view", + ), + path( + "workspaces//user-favorite-projects/", + ProjectFavoritesViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-favorite", + ), + path( + "workspaces//user-favorite-projects//", + ProjectFavoritesViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project-favorite", + ), + path( + "project-covers/", + ProjectPublicCoverImagesEndpoint.as_view(), + name="project-covers", + ), + path( + "workspaces//projects//project-deploy-boards/", + ProjectDeployBoardViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-deploy-board", + ), + path( + "workspaces//projects//project-deploy-boards//", + ProjectDeployBoardViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-deploy-board", + ), +] \ No newline at end of file diff --git a/apiserver/plane/api/urls/search.py b/apiserver/plane/app/urls/search.py similarity index 93% rename from apiserver/plane/api/urls/search.py rename to apiserver/plane/app/urls/search.py index 282feb046..05a79994e 100644 --- a/apiserver/plane/api/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( GlobalSearchEndpoint, IssueSearchEndpoint, ) diff --git a/apiserver/plane/app/urls/state.py b/apiserver/plane/app/urls/state.py new file mode 100644 index 000000000..9fec70ea1 --- /dev/null +++ b/apiserver/plane/app/urls/state.py @@ -0,0 +1,38 @@ +from django.urls import path + + +from plane.app.views import StateViewSet + + +urlpatterns = [ + path( + "workspaces//projects//states/", + StateViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-states", + ), + path( + "workspaces//projects//states//", + StateViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-state", + ), + path( + "workspaces//projects//states//mark-default/", + StateViewSet.as_view( + { + "post": "mark_as_default", + } + ), + name="project-state", + ), +] diff --git a/apiserver/plane/api/urls/user.py b/apiserver/plane/app/urls/user.py similarity index 63% rename from apiserver/plane/api/urls/user.py rename to apiserver/plane/app/urls/user.py index 5282a7cf6..9dae7b5da 100644 --- a/apiserver/plane/api/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -1,23 +1,19 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( ## User UserEndpoint, UpdateUserOnBoardedEndpoint, UpdateUserTourCompletedEndpoint, UserActivityEndpoint, ChangePasswordEndpoint, + SetUserPasswordEndpoint, ## End User ## Workspaces - UserWorkspaceInvitationsEndpoint, UserWorkSpacesEndpoint, - JoinWorkspaceEndpoint, - UserWorkspaceInvitationsEndpoint, - UserWorkspaceInvitationEndpoint, UserActivityGraphEndpoint, UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, - UserProjectInvitationsViewset, ## End Workspaces ) @@ -26,7 +22,11 @@ urlpatterns = [ path( "users/me/", UserEndpoint.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + { + "get": "retrieve", + "patch": "partial_update", + "delete": "deactivate", + } ), name="users", ), @@ -39,6 +39,15 @@ urlpatterns = [ ), name="users", ), + path( + "users/me/instance-admin/", + UserEndpoint.as_view( + { + "get": "retrieve_instance_admin", + } + ), + name="users", + ), path( "users/me/change-password/", ChangePasswordEndpoint.as_view(), @@ -55,7 +64,7 @@ urlpatterns = [ name="user-tour", ), path( - "users/workspaces//activities/", + "users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities", ), @@ -65,23 +74,6 @@ urlpatterns = [ 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="user-workspace-invitation", - ), - # user join workspace # User Graphs path( "users/me/workspaces//activity-graph/", @@ -98,16 +90,10 @@ urlpatterns = [ UserWorkspaceDashboardEndpoint.as_view(), name="user-workspace-dashboard", ), + path( + "users/me/set-password/", + SetUserPasswordEndpoint.as_view(), + name="set-password", + ), ## End User Graph - 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-invitations", - ), ] diff --git a/apiserver/plane/api/urls/views.py b/apiserver/plane/app/urls/views.py similarity index 96% rename from apiserver/plane/api/urls/views.py rename to apiserver/plane/app/urls/views.py index 560855e80..3d45b627a 100644 --- a/apiserver/plane/api/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -1,11 +1,11 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( IssueViewViewSet, GlobalViewViewSet, GlobalViewIssuesViewSet, - IssueViewFavoriteViewSet, + IssueViewFavoriteViewSet, ) diff --git a/apiserver/plane/app/urls/webhook.py b/apiserver/plane/app/urls/webhook.py new file mode 100644 index 000000000..16cc48be8 --- /dev/null +++ b/apiserver/plane/app/urls/webhook.py @@ -0,0 +1,31 @@ +from django.urls import path + +from plane.app.views import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//webhooks/", + WebhookEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhooks//", + WebhookEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhooks//regenerate/", + WebhookSecretRegenerateEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhook-logs//", + WebhookLogsEndpoint.as_view(), + name="webhooks", + ), +] diff --git a/apiserver/plane/api/urls/workspace.py b/apiserver/plane/app/urls/workspace.py similarity index 84% rename from apiserver/plane/api/urls/workspace.py rename to apiserver/plane/app/urls/workspace.py index f26730833..2c3638842 100644 --- a/apiserver/plane/api/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -1,9 +1,10 @@ from django.urls import path -from plane.api.views import ( +from plane.app.views import ( + UserWorkspaceInvitationsViewSet, WorkSpaceViewSet, - InviteWorkspaceEndpoint, + WorkspaceJoinEndpoint, WorkSpaceMemberViewSet, WorkspaceInvitationsViewset, WorkspaceMemberUserEndpoint, @@ -17,7 +18,6 @@ from plane.api.views import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, - LeaveWorkspaceEndpoint, ) @@ -49,14 +49,14 @@ urlpatterns = [ ), name="workspace", ), - path( - "workspaces//invite/", - InviteWorkspaceEndpoint.as_view(), - name="invite-workspace", - ), path( "workspaces//invitations/", - WorkspaceInvitationsViewset.as_view({"get": "list"}), + WorkspaceInvitationsViewset.as_view( + { + "get": "list", + "post": "create", + }, + ), name="workspace-invitations", ), path( @@ -65,10 +65,28 @@ urlpatterns = [ { "delete": "destroy", "get": "retrieve", + "patch": "partial_update", } ), name="workspace-invitations", ), + # user workspace invitations + path( + "users/me/workspaces/invitations/", + UserWorkspaceInvitationsViewSet.as_view( + { + "get": "list", + "post": "create", + }, + ), + name="user-workspace-invitations", + ), + path( + "workspaces//invitations//join/", + WorkspaceJoinEndpoint.as_view(), + name="workspace-join", + ), + # user join workspace path( "workspaces//members/", WorkSpaceMemberViewSet.as_view({"get": "list"}), @@ -85,6 +103,15 @@ urlpatterns = [ ), name="workspace-member", ), + path( + "workspaces//members/leave/", + WorkSpaceMemberViewSet.as_view( + { + "post": "leave", + }, + ), + name="leave-workspace-members", + ), path( "workspaces//teams/", TeamMemberViewSet.as_view( @@ -168,9 +195,4 @@ urlpatterns = [ WorkspaceLabelsEndpoint.as_view(), name="workspace-labels", ), - path( - "workspaces//members/leave/", - LeaveWorkspaceEndpoint.as_view(), - name="leave-workspace-members", - ), ] diff --git a/apiserver/plane/api/urls_deprecated.py b/apiserver/plane/app/urls_deprecated.py similarity index 96% rename from apiserver/plane/api/urls_deprecated.py rename to apiserver/plane/app/urls_deprecated.py index 67cc62e46..c6e6183fa 100644 --- a/apiserver/plane/api/urls_deprecated.py +++ b/apiserver/plane/app/urls_deprecated.py @@ -4,7 +4,7 @@ from rest_framework_simplejwt.views import TokenRefreshView # Create your urls here. -from plane.api.views import ( +from plane.app.views import ( # Authentication SignUpEndpoint, SignInEndpoint, @@ -124,9 +124,10 @@ from plane.api.views import ( ## End Modules # Pages PageViewSet, - PageBlockViewSet, + PageLogEndpoint, + SubPagesEndpoint, PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, + CreateIssueFromBlockEndpoint, ## End Pages # Api Tokens ApiTokenEndpoint, @@ -1222,25 +1223,81 @@ urlpatterns = [ name="project-pages", ), path( - "workspaces//projects//pages//page-blocks/", - PageBlockViewSet.as_view( + "workspaces//projects//pages//archive/", + PageViewSet.as_view( + { + "post": "archive", + } + ), + name="project-page-archive", + ), + path( + "workspaces//projects//pages//unarchive/", + PageViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-page-unarchive" + ), + path( + "workspaces//projects//archived-pages/", + PageViewSet.as_view( + { + "get": "archive_list", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//lock/", + PageViewSet.as_view( + { + "post": "lock", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//unlock/", + PageViewSet.as_view( + { + "post": "unlock", + } + ) + ), + path( + "workspaces//projects//pages//transactions/", + PageLogEndpoint.as_view(), name="page-transactions" + ), + path( + "workspaces//projects//pages//transactions//", + PageLogEndpoint.as_view(), name="page-transactions" + ), + path( + "workspaces//projects//pages//sub-pages/", + SubPagesEndpoint.as_view(), name="sub-page" + ), + path( + "workspaces//projects//estimates/", + BulkEstimatePointEndpoint.as_view( { "get": "list", "post": "create", } ), - name="project-page-blocks", + name="bulk-create-estimate-points", ), path( - "workspaces//projects//pages//page-blocks//", - PageBlockViewSet.as_view( + "workspaces//projects//estimates//", + BulkEstimatePointEndpoint.as_view( { "get": "retrieve", "patch": "partial_update", "delete": "destroy", } ), - name="project-page-blocks", + name="bulk-create-estimate-points", ), path( "workspaces//projects//user-favorite-pages/", @@ -1263,7 +1320,7 @@ urlpatterns = [ ), path( "workspaces//projects//pages//page-blocks//issues/", - CreateIssueFromPageBlockEndpoint.as_view(), + CreateIssueFromBlockEndpoint.as_view(), name="page-block-issues", ), ## End Pages diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py new file mode 100644 index 000000000..c122dce9f --- /dev/null +++ b/apiserver/plane/app/views/__init__.py @@ -0,0 +1,170 @@ +from .project import ( + ProjectViewSet, + ProjectMemberViewSet, + UserProjectInvitationsViewset, + ProjectInvitationsViewset, + AddTeamToProjectEndpoint, + ProjectIdentifierEndpoint, + ProjectJoinEndpoint, + ProjectUserViewsEndpoint, + ProjectMemberUserEndpoint, + ProjectFavoritesViewSet, + ProjectPublicCoverImagesEndpoint, + ProjectDeployBoardViewSet, + UserProjectRolesEndpoint, +) +from .user import ( + UserEndpoint, + UpdateUserOnBoardedEndpoint, + UpdateUserTourCompletedEndpoint, + UserActivityEndpoint, +) + +from .oauth import OauthEndpoint + +from .base import BaseAPIView, BaseViewSet, WebhookMixin + +from .workspace import ( + WorkSpaceViewSet, + UserWorkSpacesEndpoint, + WorkSpaceAvailabilityCheckEndpoint, + WorkspaceJoinEndpoint, + WorkSpaceMemberViewSet, + TeamMemberViewSet, + WorkspaceInvitationsViewset, + UserWorkspaceInvitationsViewSet, + UserLastProjectWithWorkspaceEndpoint, + WorkspaceMemberUserEndpoint, + WorkspaceMemberUserViewsEndpoint, + UserActivityGraphEndpoint, + UserIssueCompletedGraphEndpoint, + UserWorkspaceDashboardEndpoint, + WorkspaceThemeViewSet, + WorkspaceUserProfileStatsEndpoint, + WorkspaceUserActivityEndpoint, + WorkspaceUserProfileEndpoint, + WorkspaceUserProfileIssuesEndpoint, + WorkspaceLabelsEndpoint, +) +from .state import StateViewSet +from .view import ( + GlobalViewViewSet, + GlobalViewIssuesViewSet, + IssueViewViewSet, + IssueViewFavoriteViewSet, +) +from .cycle import ( + CycleViewSet, + CycleIssueViewSet, + CycleDateCheckEndpoint, + CycleFavoriteViewSet, + TransferCycleIssueEndpoint, +) +from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet +from .issue import ( + IssueViewSet, + WorkSpaceIssuesEndpoint, + IssueActivityEndpoint, + IssueCommentViewSet, + IssueUserDisplayPropertyEndpoint, + LabelViewSet, + BulkDeleteIssuesEndpoint, + UserWorkSpaceIssues, + SubIssuesEndpoint, + IssueLinkViewSet, + BulkCreateIssueLabelsEndpoint, + IssueAttachmentEndpoint, + IssueArchiveViewSet, + IssueSubscriberViewSet, + CommentReactionViewSet, + IssueReactionViewSet, + IssueRelationViewSet, + IssueDraftViewSet, +) + +from .auth_extended import ( + ForgotPasswordEndpoint, + ResetPasswordEndpoint, + ChangePasswordEndpoint, + SetUserPasswordEndpoint, + EmailCheckEndpoint, + MagicGenerateEndpoint, +) + + +from .authentication import ( + SignInEndpoint, + SignOutEndpoint, + MagicSignInEndpoint, +) + +from .module import ( + ModuleViewSet, + ModuleIssueViewSet, + ModuleLinkViewSet, + ModuleFavoriteViewSet, +) + +from .api import ApiTokenEndpoint + +from .integration import ( + WorkspaceIntegrationViewSet, + IntegrationViewSet, + GithubIssueSyncViewSet, + GithubRepositorySyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, + BulkCreateGithubIssueSyncEndpoint, + SlackProjectSyncViewSet, +) + +from .importer import ( + ServiceIssueImportSummaryEndpoint, + ImportServiceEndpoint, + UpdateServiceImportStatusEndpoint, + BulkImportIssuesEndpoint, + BulkImportModulesEndpoint, +) + +from .page import ( + PageViewSet, + PageFavoriteViewSet, + PageLogEndpoint, + SubPagesEndpoint, +) + +from .search import GlobalSearchEndpoint, IssueSearchEndpoint + + +from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint + +from .estimate import ( + ProjectEstimatePointEndpoint, + BulkEstimatePointEndpoint, +) + +from .inbox import InboxViewSet, InboxIssueViewSet + +from .analytic import ( + AnalyticsEndpoint, + AnalyticViewViewset, + SavedAnalyticEndpoint, + ExportAnalyticsEndpoint, + DefaultAnalyticsEndpoint, +) + +from .notification import ( + NotificationViewSet, + UnreadNotificationEndpoint, + MarkAllReadNotificationViewSet, +) + +from .exporter import ExportIssuesEndpoint + +from .config import ConfigurationEndpoint + +from .webhook import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) diff --git a/apiserver/plane/api/views/analytic.py b/apiserver/plane/app/views/analytic.py similarity index 98% rename from apiserver/plane/api/views/analytic.py rename to apiserver/plane/app/views/analytic.py index c29a4b692..c1deb0d8f 100644 --- a/apiserver/plane/api/views/analytic.py +++ b/apiserver/plane/app/views/analytic.py @@ -5,13 +5,12 @@ from django.db.models.functions import ExtractMonth # Third party imports from rest_framework import status from rest_framework.response import Response -from sentry_sdk import capture_exception # Module imports -from plane.api.views import BaseAPIView, BaseViewSet -from plane.api.permissions import WorkSpaceAdminPermission +from plane.app.views import BaseAPIView, BaseViewSet +from plane.app.permissions import WorkSpaceAdminPermission from plane.db.models import Issue, AnalyticView, Workspace, State, Label -from plane.api.serializers import AnalyticViewSerializer +from plane.app.serializers import AnalyticViewSerializer from plane.utils.analytics_plot import build_graph_plot from plane.bgtasks.analytic_plot_export import analytic_export_task from plane.utils.issue_filters import issue_filters diff --git a/apiserver/plane/app/views/api.py b/apiserver/plane/app/views/api.py new file mode 100644 index 000000000..ce2d4bd09 --- /dev/null +++ b/apiserver/plane/app/views/api.py @@ -0,0 +1,78 @@ +# Python import +from uuid import uuid4 + +# Third party +from rest_framework.response import Response +from rest_framework import status + +# Module import +from .base import BaseAPIView +from plane.db.models import APIToken, Workspace +from plane.app.serializers import APITokenSerializer, APITokenReadSerializer +from plane.app.permissions import WorkspaceOwnerPermission + + +class ApiTokenEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug): + label = request.data.get("label", str(uuid4().hex)) + description = request.data.get("description", "") + workspace = Workspace.objects.get(slug=slug) + expired_at = request.data.get("expired_at", None) + + # Check the user type + user_type = 1 if request.user.is_bot else 0 + + api_token = APIToken.objects.create( + label=label, + description=description, + user=request.user, + workspace=workspace, + user_type=user_type, + expired_at=expired_at, + ) + + serializer = APITokenSerializer(api_token) + # Token will be only visible while creating + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + ) + + def get(self, request, slug, pk=None): + if pk == None: + api_tokens = APIToken.objects.filter( + user=request.user, workspace__slug=slug + ) + serializer = APITokenReadSerializer(api_tokens, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + api_tokens = APIToken.objects.get( + user=request.user, workspace__slug=slug, pk=pk + ) + serializer = APITokenReadSerializer(api_tokens) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, slug, pk): + api_token = APIToken.objects.get( + workspace__slug=slug, + user=request.user, + pk=pk, + ) + api_token.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def patch(self, request, slug, pk): + api_token = APIToken.objects.get( + workspace__slug=slug, + user=request.user, + pk=pk, + ) + serializer = APITokenSerializer(api_token, data=request.data, 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) diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/app/views/asset.py similarity index 51% rename from apiserver/plane/api/views/asset.py rename to apiserver/plane/app/views/asset.py index 3f5dcceac..17d70d936 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/app/views/asset.py @@ -1,17 +1,16 @@ # Third party imports from rest_framework import status from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser -from sentry_sdk import capture_exception -from django.conf import settings +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser + # Module imports -from .base import BaseAPIView +from .base import BaseAPIView, BaseViewSet from plane.db.models import FileAsset, Workspace -from plane.api.serializers import FileAssetSerializer +from plane.app.serializers import FileAssetSerializer class FileAssetEndpoint(BaseAPIView): - parser_classes = (MultiPartParser, FormParser) + parser_classes = (MultiPartParser, FormParser, JSONParser,) """ A viewset for viewing and editing task instances. @@ -26,7 +25,6 @@ class FileAssetEndpoint(BaseAPIView): else: return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) - def post(self, request, slug): serializer = FileAssetSerializer(data=request.data) if serializer.is_valid(): @@ -35,15 +33,22 @@ class FileAssetEndpoint(BaseAPIView): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - + def delete(self, request, workspace_id, asset_key): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) - # Delete the file from storage - file_asset.asset.delete(save=False) - # Delete the file object - file_asset.delete() + file_asset.is_deleted = True + file_asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class FileAssetViewSet(BaseViewSet): + + def restore(self, request, workspace_id, asset_key): + asset_key = str(workspace_id) + "/" + asset_key + file_asset = FileAsset.objects.get(asset=asset_key) + file_asset.is_deleted = False + file_asset.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -51,25 +56,23 @@ class UserAssetsEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser) def get(self, request, asset_key): - files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) - if files.exists(): - serializer = FileAssetSerializer(files, context={"request": request}) - return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) - else: - return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) + files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) + if files.exists(): + serializer = FileAssetSerializer(files, context={"request": request}) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + else: + return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) def post(self, request): - 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) + 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) def delete(self, request, asset_key): - file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) - # Delete the file from storage - file_asset.asset.delete(save=False) - # Delete the file object - file_asset.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) + file_asset.is_deleted = True + file_asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py new file mode 100644 index 000000000..049e5aab9 --- /dev/null +++ b/apiserver/plane/app/views/auth_extended.py @@ -0,0 +1,467 @@ +## Python imports +import uuid +import os +import json +import random +import string + +## Django imports +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.encoding import ( + smart_str, + smart_bytes, + DjangoUnicodeDecodeError, +) +from django.contrib.auth.hashers import make_password +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.core.validators import validate_email +from django.core.exceptions import ValidationError +from django.conf import settings + +## Third Party Imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework_simplejwt.tokens import RefreshToken + +## Module imports +from . import BaseAPIView +from plane.app.serializers import ( + ChangePasswordSerializer, + ResetPasswordSerializer, + UserSerializer, +) +from plane.db.models import User, WorkspaceMemberInvite +from plane.license.utils.instance_value import get_configuration_value +from plane.bgtasks.forgot_password_task import forgot_password +from plane.license.models import Instance +from plane.settings.redis import redis_instance +from plane.bgtasks.magic_link_code_task import magic_link +from plane.bgtasks.event_tracking_task import auth_events + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +def generate_magic_token(email): + key = "magic_" + str(email) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + ) + + # Initialize the redis instance + ri = redis_instance() + + # 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 key, token, False + + 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) + + return key, token, True + + +def generate_password_token(user): + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + return uidb64, token + + +class ForgotPasswordEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email") + + try: + validate_email(email) + except ValidationError: + return Response( + {"error": "Please enter a valid email"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = generate_password_token(user=user) + current_site = request.META.get("HTTP_ORIGIN") + # send the forgot password email + forgot_password.delay( + user.first_name, user.email, uidb64, token, current_site + ) + return Response( + {"message": "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 = [ + AllowAny, + ] + + def post(self, request, uidb64, token): + try: + # Decode the id from the uidb64 + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + # check if the token is valid for the user + if not PasswordResetTokenGenerator().check_token(user, token): + return Response( + {"error": "Token is invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + # Reset the password + 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.is_password_autoset = False + user.save() + + # Log the user in + # Generate access token for the user + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return Response(data, status=status.HTTP_200_OK) + 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): + serializer = ChangePasswordSerializer(data=request.data) + user = User.objects.get(pk=request.user.id) + if serializer.is_valid(): + if not user.check_password(serializer.data.get("old_password")): + return Response( + {"error": "Old password is not correct"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # set_password also hashes the password that the user will get + user.set_password(serializer.data.get("new_password")) + user.is_password_autoset = False + user.save() + return Response( + {"message": "Password updated successfully"}, status=status.HTTP_200_OK + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class SetUserPasswordEndpoint(BaseAPIView): + def post(self, request): + user = User.objects.get(pk=request.user.id) + password = request.data.get("password", False) + + # If the user password is not autoset then return error + if not user.is_password_autoset: + return Response( + { + "error": "Your password is already set please change your password from profile" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check password validation + if not password and len(str(password)) < 8: + return Response( + {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Set the user password + user.set_password(password) + user.is_password_autoset = False + user.save() + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class MagicGenerateEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + email = request.data.get("email", False) + + # Check the instance registration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not email: + return Response( + {"error": "Please provide a valid email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Clean up the email + email = email.strip().lower() + validate_email(email) + + # check if the email exists not + if not User.objects.filter(email=email).exists(): + # Create a user + _ = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + ## Generate a random token + token = ( + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, k=4)) + + "-" + + "".join(random.choices(string.ascii_lowercase, 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) + + # If the smtp is configured send through here + current_site = request.META.get("HTTP_ORIGIN") + magic_link.delay(email, key, token, current_site) + + return Response({"key": key}, status=status.HTTP_200_OK) + + +class EmailCheckEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check the instance registration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get configuration values + ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), + }, + ] + ) + + email = request.data.get("email", False) + + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # validate the email + try: + validate_email(email) + except ValidationError: + return Response( + {"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Check if the user exists + user = User.objects.filter(email=email).first() + current_site = request.META.get("HTTP_ORIGIN") + + # If new user + if user is None: + # Create the user + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create the user with default values + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + if not bool( + ENABLE_MAGIC_LINK_LOGIN, + ): + return Response( + {"error": "Magic link sign in is disabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Send event + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=True, + ) + key, token, current_attempt = generate_magic_token(email=email) + if not current_attempt: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Trigger the email + magic_link.delay(email, "magic_" + str(email), token, current_site) + return Response( + {"is_password_autoset": user.is_password_autoset, "is_existing": False}, + status=status.HTTP_200_OK, + ) + + # Existing user + else: + if user.is_password_autoset: + ## Generate a random token + if not bool(ENABLE_MAGIC_LINK_LOGIN): + return Response( + {"error": "Magic link sign in is disabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, + ) + + # Generate magic token + key, token, current_attempt = generate_magic_token(email=email) + if not current_attempt: + return Response( + {"error": "Max attempts exhausted. Please try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Trigger the email + magic_link.delay(email, key, token, current_site) + return Response( + { + "is_password_autoset": user.is_password_autoset, + "is_existing": True, + }, + status=status.HTTP_200_OK, + ) + else: + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=False, + ) + + # User should enter password to login + return Response( + { + "is_password_autoset": user.is_password_autoset, + "is_existing": True, + }, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py new file mode 100644 index 000000000..811eeb959 --- /dev/null +++ b/apiserver/plane/app/views/authentication.py @@ -0,0 +1,449 @@ +# Python imports +import os +import uuid +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 +from django.contrib.auth.hashers import make_password + +# 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_message + +# Module imports +from . import BaseAPIView +from plane.db.models import ( + User, + WorkspaceMemberInvite, + WorkspaceMember, + ProjectMemberInvite, + ProjectMember, +) +from plane.settings.redis import redis_instance +from plane.license.models import Instance +from plane.license.utils.instance_value import get_configuration_value +from plane.bgtasks.event_tracking_task import auth_events + + +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): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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, + ) + + # Validate the email + 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, + ) + + # get configuration values + # Get configuration values + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + ] + ) + + # If the sign up is not enabled and the user does not have invite disallow him from creating the account + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the user already exists + if User.objects.filter(email=email).exists(): + return Response( + {"error": "User with this email already exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create(email=email, username=uuid.uuid4().hex) + user.set_password(password) + + # settings last actives for the user + user.is_password_autoset = False + 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, + } + + return Response(data, status=status.HTTP_200_OK) + + +class SignInEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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, + ) + + # Validate email + 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, + ) + + # Get the user + user = User.objects.filter(email=email).first() + + # Existing user + if user: + # Check user password + 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, + ) + + # Create the user + else: + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + ] + ) + # Create the user + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, + ) + + # settings last active for the user + user.is_active = True + 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() + + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + # Send event + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=False, + ) + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + return Response(data, status=status.HTTP_200_OK) + + +class SignOutEndpoint(BaseAPIView): + def post(self, request): + refresh_token = request.data.get("refresh_token", False) + + if not refresh_token: + capture_message("No refresh token provided") + return Response( + {"error": "No refresh token provided"}, + 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) + + +class MagicSignInEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user_token = request.data.get("token", "").strip() + key = request.data.get("key", False).strip().lower() + + 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): + user = User.objects.get(email=email) + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) + # Send event + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, + ) + + user.is_active = True + user.is_email_verified = True + 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() + + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + 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, + ) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py new file mode 100644 index 000000000..32449597b --- /dev/null +++ b/apiserver/plane/app/views/base.py @@ -0,0 +1,241 @@ +# Python imports +import zoneinfo +import json + +# Django imports +from django.urls import resolve +from django.conf import settings +from django.utils import timezone +from django.db import IntegrityError +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.serializers.json import DjangoJSONEncoder + +# Third part imports +from rest_framework import status +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +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 sentry_sdk import capture_exception +from django_filters.rest_framework import DjangoFilterBackend + +# Module imports +from plane.utils.paginator import BasePaginator +from plane.bgtasks.webhook_task import send_webhook + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class WebhookMixin: + webhook_event = None + bulk = False + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + + # Check for the case should webhook be sent + if ( + self.webhook_event + and self.request.method in ["POST", "PATCH", "DELETE"] + and response.status_code in [200, 201, 204] + ): + # Push the object to delay + send_webhook.delay( + event=self.webhook_event, + payload=response.data, + kw=self.kwargs, + action=self.request.method, + slug=self.workspace_slug, + bulk=self.bulk, + ) + + return response + + +class BaseViewSet(TimezoneMixin, 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: + capture_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + model_name = str(exc).split(" matching query does not exist.")[0] + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + capture_exception(e) + return Response( + {"error": f"key {e} does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + print(e) if settings.DEBUG else print("Server Error") + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) + + return response + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", 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) + + +class BaseAPIView(TimezoneMixin, 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 handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + model_name = str(exc).split(" matching query does not exist.")[0] + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + + if settings.DEBUG: + print(e) + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + return self.kwargs.get("project_id", None) diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py new file mode 100644 index 000000000..fb32e8570 --- /dev/null +++ b/apiserver/plane/app/views/config.py @@ -0,0 +1,120 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.license.utils.instance_value import get_configuration_value + + +class ConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + + # Get all the configuration + ( + GOOGLE_CLIENT_ID, + GITHUB_CLIENT_ID, + GITHUB_APP_NAME, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + SLACK_CLIENT_ID, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID", None), + }, + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID", None), + }, + { + "key": "GITHUB_APP_NAME", + "default": os.environ.get("GITHUB_APP_NAME", None), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER", None), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD", None), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "SLACK_CLIENT_ID", + "default": os.environ.get("SLACK_CLIENT_ID", "1"), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", "1"), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", "1"), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), + }, + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", "1"), + }, + ] + ) + + data = {} + # Authentication + data["google_client_id"] = GOOGLE_CLIENT_ID + data["github_client_id"] = GITHUB_CLIENT_ID + data["github_app_name"] = GITHUB_APP_NAME + data["magic_login"] = ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) and ENABLE_MAGIC_LINK_LOGIN == "1" + + data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" + # Slack client + data["slack_client_id"] = SLACK_CLIENT_ID + + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + # Unsplash + data["has_unsplash_configured"] = UNSPLASH_ACCESS_KEY + + # Open AI settings + data["has_openai_configured"] = bool(OPENAI_API_KEY) + + # File size settings + data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + + # is self managed + data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) + + return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py new file mode 100644 index 000000000..d2f82d75b --- /dev/null +++ b/apiserver/plane/app/views/cycle.py @@ -0,0 +1,808 @@ +# Python imports +import json + +# Django imports +from django.db.models import ( + Func, + F, + Q, + Exists, + OuterRef, + Count, + Prefetch, + Sum, +) +from django.core import serializers +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.serializers import ( + CycleSerializer, + CycleIssueSerializer, + CycleFavoriteSerializer, + IssueStateSerializer, + CycleWriteSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + User, + Cycle, + CycleIssue, + Issue, + CycleFavorite, + IssueLink, + IssueAttachment, + Label, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters +from plane.utils.analytics_plot import burndown_plot + + +class CycleViewSet(WebhookMixin, BaseViewSet): + serializer_class = CycleSerializer + model = Cycle + webhook_event = "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): + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + 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("owned_by") + .annotate(is_favorite=Exists(subquery)) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only("avatar", "first_name", "id").distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only("name", "color", "id").distinct(), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + def list(self, request, slug, project_id): + queryset = self.get_queryset() + cycle_view = request.GET.get("cycle_view", "all") + + queryset = queryset.order_by("-is_favorite","-created_at") + + # Current Cycle + if cycle_view == "current": + queryset = queryset.filter( + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ) + + data = CycleSerializer(queryset, many=True).data + + if len(data): + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data[0]["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) + + return Response(data, status=status.HTTP_200_OK) + + # Upcoming Cycles + if cycle_view == "upcoming": + queryset = queryset.filter(start_date__gt=timezone.now()) + return Response( + CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + # Completed Cycles + if cycle_view == "completed": + queryset = queryset.filter(end_date__lt=timezone.now()) + return Response( + CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + # Draft Cycles + if cycle_view == "draft": + queryset = queryset.filter( + end_date=None, + start_date=None, + ) + + return Response( + CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + # Incomplete Cycles + if cycle_view == "incomplete": + queryset = queryset.filter( + Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), + ) + return Response( + CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + # If no matching view is found return all cycles + return Response( + CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + def create(self, request, slug, project_id): + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + serializer = CycleSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + owned_by=request.user, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + { + "error": "Both start date and end date are either required or are to be null" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + def partial_update(self, request, slug, project_id, pk): + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + + request_data = request.data + + if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if "sort_order" in request_data: + # Can only change sort order + request_data = { + "sort_order": request_data.get("sort_order", cycle.sort_order) + } + else: + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CycleWriteSerializer(cycle, data=request.data, 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) + + def retrieve(self, request, slug, project_id, pk): + queryset = self.get_queryset().get(pk=pk) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate(display_name=F("assignees__display_name")) + .values("first_name", "last_name", "assignee_id", "avatar", "display_name") + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + # Label Distribution + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data = CycleSerializer(queryset).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.end_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, pk): + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(pk), + "cycle_name": str(cycle.name), + "issues": [str(issue_id) for issue_id in cycle_issues], + } + ), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # Delete the cycle + cycle.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleIssueViewSet(WebhookMixin, BaseViewSet): + serializer_class = CycleIssueSerializer + model = CycleIssue + + webhook_event = "cycle_issue" + bulk = True + + permission_classes = [ + ProjectEntityPermission, + ] + + filterset_fields = [ + "issue__labels__id", + "issue__assignees__id", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .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", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .distinct() + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, cycle_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + order_by = request.GET.get("order_by", "created_at") + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_cycle__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .filter(**filters) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id, cycle_id): + 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 + ) + + if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + return Response( + { + "error": "The Cycle has already been completed so no new issues can be added" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all CycleIssues already created + cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) + update_cycle_issue_activity = [] + record_to_create = [] + records_to_update = [] + + for issue in issues: + cycle_issue = [ + cycle_issue + for cycle_issue in cycle_issues + if str(cycle_issue.issue_id) in issues + ] + # Update only when cycle changes + if len(cycle_issue): + if cycle_issue[0].cycle_id != cycle_id: + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_issue[0].cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue[0].issue_id), + } + ) + cycle_issue[0].cycle_id = cycle_id + records_to_update.append(cycle_issue[0]) + else: + record_to_create.append( + CycleIssue( + project_id=project_id, + workspace=cycle.workspace, + created_by=request.user, + updated_by=request.user, + cycle=cycle, + issue_id=issue, + ) + ) + + CycleIssue.objects.bulk_create( + record_to_create, + batch_size=10, + ignore_conflicts=True, + ) + CycleIssue.objects.bulk_update( + records_to_update, + ["cycle"], + batch_size=10, + ) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": issues}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", record_to_create + ), + } + ), + epoch=int(timezone.now().timestamp()), + ) + + # Return all Cycle Issues + return Response( + CycleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, cycle_id, pk): + cycle_issue = CycleIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + ) + issue_id = cycle_issue.issue_id + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("cycle_id")), + "issues": [str(issue_id)], + } + ), + actor_id=str(self.request.user.id), + issue_id=str(cycle_issue.issue_id), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + cycle_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleDateCheckEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + start_date = request.data.get("start_date", False) + end_date = request.data.get("end_date", False) + cycle_id = request.data.get("cycle_id") + if not start_date or not end_date: + return Response( + {"error": "Start date and end date both are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycles = Cycle.objects.filter( + Q(workspace__slug=slug) + & Q(project_id=project_id) + & ( + Q(start_date__lte=start_date, end_date__gte=start_date) + | Q(start_date__lte=end_date, end_date__gte=end_date) + | Q(start_date__gte=start_date, end_date__lte=end_date) + ) + ).exclude(pk=cycle_id) + + if cycles.exists(): + return Response( + { + "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", + "status": False, + } + ) + else: + return Response({"status": True}, status=status.HTTP_200_OK) + + +class CycleFavoriteViewSet(BaseViewSet): + serializer_class = CycleFavoriteSerializer + model = CycleFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("cycle", "cycle__owned_by") + ) + + def create(self, request, slug, project_id): + serializer = CycleFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, cycle_id): + cycle_favorite = CycleFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + cycle_id=cycle_id, + ) + cycle_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TransferCycleIssueEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, cycle_id): + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ) + + if ( + new_cycle.end_date is not None + and new_cycle.end_date < timezone.now().date() + ): + return Response( + { + "error": "The cycle where the issues are transferred is already completed" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + return Response({"message": "Success"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/apiserver/plane/api/views/estimate.py b/apiserver/plane/app/views/estimate.py similarity index 97% rename from apiserver/plane/api/views/estimate.py rename to apiserver/plane/app/views/estimate.py index 3c2cca4d5..ec9393f5b 100644 --- a/apiserver/plane/api/views/estimate.py +++ b/apiserver/plane/app/views/estimate.py @@ -1,13 +1,12 @@ # Third party imports from rest_framework.response import Response from rest_framework import status -from sentry_sdk import capture_exception # Module imports from .base import BaseViewSet, BaseAPIView -from plane.api.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission from plane.db.models import Project, Estimate, EstimatePoint -from plane.api.serializers import ( +from plane.app.serializers import ( EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer, diff --git a/apiserver/plane/api/views/exporter.py b/apiserver/plane/app/views/exporter.py similarity index 94% rename from apiserver/plane/api/views/exporter.py rename to apiserver/plane/app/views/exporter.py index 03da8932f..b709a599d 100644 --- a/apiserver/plane/api/views/exporter.py +++ b/apiserver/plane/app/views/exporter.py @@ -1,15 +1,14 @@ # Third Party imports from rest_framework.response import Response from rest_framework import status -from sentry_sdk import capture_exception # Module imports from . import BaseAPIView -from plane.api.permissions import WorkSpaceAdminPermission +from plane.app.permissions import WorkSpaceAdminPermission from plane.bgtasks.export_task import issue_export_task from plane.db.models import Project, ExporterHistory, Workspace -from plane.api.serializers import ExporterHistorySerializer +from plane.app.serializers import ExporterHistorySerializer class ExportIssuesEndpoint(BaseAPIView): diff --git a/apiserver/plane/api/views/external.py b/apiserver/plane/app/views/external.py similarity index 60% rename from apiserver/plane/api/views/external.py rename to apiserver/plane/app/views/external.py index a04495569..97d509c1e 100644 --- a/apiserver/plane/api/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -1,22 +1,22 @@ # Python imports import requests +import os # Third party imports -import openai +from openai import OpenAI from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import AllowAny -from sentry_sdk import capture_exception # Django imports from django.conf import settings # Module imports from .base import BaseAPIView -from plane.api.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project -from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.utils.integrations.github import get_release_notes +from plane.license.utils.instance_value import get_configuration_value class GPTIntegrationEndpoint(BaseAPIView): @@ -25,7 +25,22 @@ class GPTIntegrationEndpoint(BaseAPIView): ] def post(self, request, slug, project_id): - if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE: + OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( + [ + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", None), + }, + { + "key": "GPT_ENGINE", + "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + }, + ] + ) + + # Get the configuration value + # Check the keys + if not OPENAI_API_KEY or not GPT_ENGINE: return Response( {"error": "OpenAI API key and engine is required"}, status=status.HTTP_400_BAD_REQUEST, @@ -41,12 +56,13 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt - openai.api_key = settings.OPENAI_API_KEY - response = openai.ChatCompletion.create( - model=settings.GPT_ENGINE, + client = OpenAI( + api_key=OPENAI_API_KEY, + ) + + response = client.chat.completions.create( + model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}], - temperature=0.7, - max_tokens=1024, ) workspace = Workspace.objects.get(slug=slug) @@ -72,16 +88,28 @@ class ReleaseNotesEndpoint(BaseAPIView): class UnsplashEndpoint(BaseAPIView): - def get(self, request): + UNSPLASH_ACCESS_KEY, = get_configuration_value( + [ + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY"), + } + ] + ) + # Check unsplash access key + if not UNSPLASH_ACCESS_KEY: + return Response([], status=status.HTTP_200_OK) + + # Query parameters query = request.GET.get("query", False) page = request.GET.get("page", 1) per_page = request.GET.get("per_page", 20) url = ( - f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + f"https://api.unsplash.com/search/photos/?client_id={UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" if query - else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" ) headers = { diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/app/views/importer.py similarity index 99% rename from apiserver/plane/api/views/importer.py rename to apiserver/plane/app/views/importer.py index 4060b2bd5..b99d663e2 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/app/views/importer.py @@ -4,13 +4,12 @@ import uuid # Third party imports from rest_framework import status from rest_framework.response import Response -from sentry_sdk import capture_exception # Django imports from django.db.models import Max, Q # Module imports -from plane.api.views import BaseAPIView +from plane.app.views import BaseAPIView from plane.db.models import ( WorkspaceIntegration, Importer, @@ -30,7 +29,7 @@ from plane.db.models import ( ModuleIssue, Label, ) -from plane.api.serializers import ( +from plane.app.serializers import ( ImporterSerializer, IssueFlatSerializer, ModuleSerializer, @@ -39,7 +38,7 @@ from plane.utils.integrations.github import get_github_repo_details from plane.utils.importers.jira import jira_project_issue_summary from plane.bgtasks.importer_task import service_importer from plane.utils.html_processor import strip_tags -from plane.api.permissions import WorkSpaceAdminPermission +from plane.app.permissions import WorkSpaceAdminPermission class ServiceIssueImportSummaryEndpoint(BaseAPIView): diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py new file mode 100644 index 000000000..331ee2175 --- /dev/null +++ b/apiserver/plane/app/views/inbox.py @@ -0,0 +1,358 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseViewSet +from plane.app.permissions import ProjectBasePermission, ProjectLitePermission +from plane.db.models import ( + Inbox, + InboxIssue, + Issue, + State, + IssueLink, + IssueAttachment, + ProjectMember, +) +from plane.app.serializers import ( + IssueSerializer, + InboxSerializer, + InboxIssueSerializer, + IssueCreateSerializer, + IssueStateInboxSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activites_task import issue_activity + + +class InboxViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + + serializer_class = InboxSerializer + model = Inbox + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .annotate( + pending_issue_count=Count( + "issue_inbox", + filter=Q(issue_inbox__status=-2), + ) + ) + .select_related("workspace", "project") + ) + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def destroy(self, request, slug, project_id, pk): + inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + # Handle default inbox delete + if inbox.is_default: + return Response( + {"error": "You cannot delete the default inbox"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inbox.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InboxIssueViewSet(BaseViewSet): + permission_classes = [ + ProjectLitePermission, + ] + + serializer_class = InboxIssueSerializer + model = InboxIssue + + filterset_fields = [ + "status", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + inbox_id=self.kwargs.get("inbox_id"), + ) + .select_related("issue", "workspace", "project") + ) + + def list(self, request, slug, project_id, inbox_id): + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter( + issue_inbox__inbox_id=inbox_id, + workspace__slug=slug, + project_id=project_id, + ) + .filter(**filters) + .annotate(bridge_id=F("issue_inbox__id")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + ) + issues_data = IssueStateInboxSerializer(issues, many=True).data + return Response( + issues_data, + status=status.HTTP_200_OK, + ) + + def create(self, request, slug, project_id, inbox_id): + if not request.data.get("issue", {}).get("name", False): + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Check for valid priority + if not request.data.get("issue", {}).get("priority", "none") in [ + "low", + "medium", + "high", + "urgent", + "none", + ]: + return Response( + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Create or get state + state, _ = State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=project_id, + color="#ff7700", + ) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get( + "description_html", "

" + ), + priority=request.data.get("issue", {}).get("priority", "low"), + project_id=project_id, + state=state, + ) + + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # create an inbox issue + InboxIssue.objects.create( + inbox_id=inbox_id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) + + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, inbox_id, pk): + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + # Get the project member + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + # Only project members admins and created_by users can access this endpoint + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + + if bool(issue_data): + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + # Only allow guests and viewers to edit name and description + if project_member.role <= 10: + # viewers and guests since only viewers and guests + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get("description", issue.description), + } + + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + issue_serializer.save() + else: + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + # Only project admins and members can edit inbox issue attributes + if project_member.role > 10: + serializer = InboxIssueSerializer( + inbox_issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + # Update the issue state if the issue is rejected or marked as duplicate + if serializer.data["status"] in [-1, 2]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + state = State.objects.filter( + group="cancelled", workspace__slug=slug, project_id=project_id + ).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + + # Update the issue state only if it is in triage state + if issue.state.name == "Triage": + # Move to default state + state = State.objects.filter( + workspace__slug=slug, project_id=project_id, default=True + ).first() + if state is not None: + issue.state = state + issue.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + ) + + def retrieve(self, request, slug, project_id, inbox_id, pk): + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, inbox_id, pk): + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + # Get the project member + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( + request.user.id + ): + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check the issue status + if inbox_issue.status in [-2, -1, 0, 2]: + # Delete the issue also + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id + ).delete() + + inbox_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/app/views/integration/__init__.py similarity index 100% rename from apiserver/plane/api/views/integration/__init__.py rename to apiserver/plane/app/views/integration/__init__.py diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/app/views/integration/base.py similarity index 97% rename from apiserver/plane/api/views/integration/base.py rename to apiserver/plane/app/views/integration/base.py index cc911b537..b82957dfb 100644 --- a/apiserver/plane/api/views/integration/base.py +++ b/apiserver/plane/app/views/integration/base.py @@ -10,7 +10,7 @@ from rest_framework import status from sentry_sdk import capture_exception # Module imports -from plane.api.views import BaseViewSet +from plane.app.views import BaseViewSet from plane.db.models import ( Integration, WorkspaceIntegration, @@ -19,12 +19,12 @@ from plane.db.models import ( WorkspaceMember, APIToken, ) -from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer +from plane.app.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer from plane.utils.integrations.github import ( get_github_metadata, delete_github_installation, ) -from plane.api.permissions import WorkSpaceAdminPermission +from plane.app.permissions import WorkSpaceAdminPermission from plane.utils.integrations.slack import slack_oauth class IntegrationViewSet(BaseViewSet): diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/app/views/integration/github.py similarity index 97% rename from apiserver/plane/api/views/integration/github.py rename to apiserver/plane/app/views/integration/github.py index f2035639e..29b7a9b2f 100644 --- a/apiserver/plane/api/views/integration/github.py +++ b/apiserver/plane/app/views/integration/github.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Module imports -from plane.api.views import BaseViewSet, BaseAPIView +from plane.app.views import BaseViewSet, BaseAPIView from plane.db.models import ( GithubIssueSync, GithubRepositorySync, @@ -15,13 +15,13 @@ from plane.db.models import ( GithubCommentSync, Project, ) -from plane.api.serializers import ( +from plane.app.serializers import ( GithubIssueSyncSerializer, GithubRepositorySyncSerializer, GithubCommentSyncSerializer, ) from plane.utils.integrations.github import get_github_repos -from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission +from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission class GithubRepositoriesEndpoint(BaseAPIView): diff --git a/apiserver/plane/api/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py similarity index 94% rename from apiserver/plane/api/views/integration/slack.py rename to apiserver/plane/app/views/integration/slack.py index 6b1b47d37..3f18a2ab2 100644 --- a/apiserver/plane/api/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -7,10 +7,10 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Module imports -from plane.api.views import BaseViewSet, BaseAPIView +from plane.app.views import BaseViewSet, BaseAPIView from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember -from plane.api.serializers import SlackProjectSyncSerializer -from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission +from plane.app.serializers import SlackProjectSyncSerializer +from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission from plane.utils.integrations.slack import slack_oauth diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py new file mode 100644 index 000000000..d489629ba --- /dev/null +++ b/apiserver/plane/app/views/issue.py @@ -0,0 +1,1629 @@ +# Python imports +import json +import random +from itertools import chain + +# Django imports +from django.db import models +from django.utils import timezone +from django.db.models import ( + Prefetch, + OuterRef, + Func, + F, + Q, + Count, + Case, + Value, + CharField, + When, + Exists, + Max, + IntegerField, +) +from django.core.serializers.json import DjangoJSONEncoder +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from django.db import IntegrityError + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.parsers import MultiPartParser, FormParser + +# Module imports +from . import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.serializers import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssuePropertySerializer, + IssueSerializer, + LabelSerializer, + IssueFlatSerializer, + IssueLinkSerializer, + IssueLiteSerializer, + IssueAttachmentSerializer, + IssueSubscriberSerializer, + ProjectMemberLiteSerializer, + IssueReactionSerializer, + CommentReactionSerializer, + IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, + IssuePublicSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + WorkSpaceAdminPermission, + ProjectMemberPermission, + ProjectLitePermission, +) +from plane.db.models import ( + Project, + Issue, + IssueActivity, + IssueComment, + IssueProperty, + Label, + IssueLink, + IssueAttachment, + State, + IssueSubscriber, + ProjectMember, + IssueReaction, + CommentReaction, + ProjectDeployBoard, + IssueVote, + IssueRelation, + ProjectPublicMember, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters + + +class IssueViewSet(WebhookMixin, BaseViewSet): + def get_serializer_class(self): + return ( + IssueCreateSerializer + if self.action in ["create", "update", "partial_update"] + else IssueSerializer + ) + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission, + ] + + search_fields = [ + "name", + ] + + filterset_fields = [ + "state__name", + "assignees__id", + "workspace__id", + ] + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .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( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save() + + # Track the issue + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk=None): + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, pk=None): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueCreateSerializer(issue, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, pk=None): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + issue.delete() + issue_activity.delay( + type="issue.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserWorkSpaceIssues(BaseAPIView): + @method_decorator(gzip_page) + def get(self, request, slug): + filters = issue_filters(request.query_params, "GET") + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + Issue.issue_objects.filter( + ( + Q(assignees__in=[request.user]) + | Q(created_by=request.user) + | Q(issue_subscribers__subscriber=request.user) + ), + workspace__slug=slug, + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by_param) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .filter(**filters) + ).distinct() + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True).data + + ## Grouping the results + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) + return Response( + grouped_results, + status=status.HTTP_200_OK, + ) + + return Response(issues, status=status.HTTP_200_OK) + + +class WorkSpaceIssuesEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceAdminPermission, + ] + + @method_decorator(gzip_page) + def get(self, request, slug): + issues = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + ) + serializer = IssueSerializer(issues, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueActivityEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + @method_decorator(gzip_page) + def get(self, request, slug, project_id, issue_id): + issue_activities = ( + IssueActivity.objects.filter(issue_id=issue_id) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + ) + .select_related("actor", "workspace", "issue", "project") + ).order_by("created_at") + issue_comments = ( + IssueComment.objects.filter(issue_id=issue_id) + .filter(project__project_projectmember__member=self.request.user) + .order_by("created_at") + .select_related("actor", "issue", "project", "workspace") + .prefetch_related( + Prefetch( + "comment_reactions", + queryset=CommentReaction.objects.select_related("actor"), + ) + ) + ) + issue_activities = IssueActivitySerializer(issue_activities, many=True).data + issue_comments = IssueCommentSerializer(issue_comments, many=True).data + + result_list = sorted( + chain(issue_activities, issue_comments), + key=lambda instance: instance["created_at"], + ) + + return Response(result_list, status=status.HTTP_200_OK) + + +class IssueCommentViewSet(WebhookMixin, BaseViewSet): + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ + ProjectLitePermission, + ] + + filterset_fields = [ + "issue__id", + "workspace__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") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .distinct() + ) + + def create(self, request, slug, project_id, issue_id): + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + issue_id=issue_id, + actor=request.user, + ) + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, + cls=DjangoJSONEncoder, + ) + serializer = IssueCommentSerializer( + issue_comment, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, + cls=DjangoJSONEncoder, + ) + issue_comment.delete() + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueUserDisplayPropertyEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + def post(self, request, slug, project_id): + 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() + issue_property.properties = request.data.get("properties", {}) + issue_property.save() + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug, project_id): + issue_property, _ = IssueProperty.objects.get_or_create( + user=request.user, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class LabelViewSet(BaseViewSet): + serializer_class = LabelSerializer + model = Label + permission_classes = [ + ProjectMemberPermission, + ] + + def create(self, request, slug, project_id): + try: + serializer = LabelSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Label with the same name already exists in the project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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() + .order_by("sort_order") + ) + + +class BulkDeleteIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def delete(self, request, slug, project_id): + 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.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, + ) + + +class SubIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + @method_decorator(gzip_page) + def get(self, request, slug, project_id, issue_id): + sub_issues = ( + Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + state_distribution = ( + State.objects.filter(workspace__slug=slug, state_issue__parent_id=issue_id) + .annotate(state_group=F("group")) + .values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + result = { + item["state_group"]: item["state_count"] for item in state_distribution + } + + serializer = IssueLiteSerializer( + sub_issues, + many=True, + ) + return Response( + { + "sub_issues": serializer.data, + "state_distribution": result, + }, + status=status.HTTP_200_OK, + ) + + # Assign multiple sub issues + def post(self, request, slug, project_id, issue_id): + parent_issue = Issue.issue_objects.get(pk=issue_id) + sub_issue_ids = request.data.get("sub_issue_ids", []) + + if not len(sub_issue_ids): + return Response( + {"error": "Sub Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) + + for sub_issue in sub_issues: + sub_issue.parent = parent_issue + + _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) + + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) + + # Track the issue + _ = [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"parent": str(issue_id)}), + actor_id=str(request.user.id), + issue_id=str(sub_issue_id), + project_id=str(project_id), + current_instance=json.dumps({"parent": str(sub_issue_id)}), + epoch=int(timezone.now().timestamp()), + ) + for sub_issue_id in sub_issue_ids + ] + + return Response( + IssueFlatSerializer(updated_sub_issues, many=True).data, + status=status.HTTP_200_OK, + ) + + +class IssueLinkViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + model = IssueLink + serializer_class = IssueLinkSerializer + + def get_queryset(self): + return ( + 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) + .order_by("-created_at") + .distinct() + ) + + def create(self, request, slug, project_id, issue_id): + serializer = IssueLinkSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + issue_id=issue_id, + ) + issue_activity.delay( + type="link.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, + cls=DjangoJSONEncoder, + ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, + cls=DjangoJSONEncoder, + ) + issue_activity.delay( + type="link.activity.deleted", + requested_data=json.dumps({"link_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkCreateIssueLabelsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + label_data = request.data.get("label_data", []) + project = Project.objects.get(pk=project_id) + + labels = Label.objects.bulk_create( + [ + Label( + name=label.get("name", "Migrated"), + description=label.get("description", "Migrated Issue"), + color="#" + "%06x" % random.randint(0, 0xFFFFFF), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for label in label_data + ], + batch_size=50, + ignore_conflicts=True, + ) + + return Response( + {"labels": LabelSerializer(labels, many=True).data}, + status=status.HTTP_201_CREATED, + ) + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + permission_classes = [ + ProjectEntityPermission, + ] + model = IssueAttachment + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, slug, project_id, issue_id): + serializer = IssueAttachmentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + serializer.data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment.asset.delete(save=False) + issue_attachment.delete() + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, project_id, issue_id): + issue_attachments = IssueAttachment.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueArchiveViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + Issue.objects.annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(archived_at__isnull=False) + .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") + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + filters = issue_filters(request.query_params, "GET") + show_sub_issues = request.GET.get("show_sub_issues", "true") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issue_queryset = ( + issue_queryset + if show_sub_issues == "true" + else issue_queryset.filter(parent__isnull=True) + ) + + issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) + + def retrieve(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + + def unarchive(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, + project_id=project_id, + archived_at__isnull=False, + pk=pk, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": None}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + ) + issue.archived_at = None + issue.save() + + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + + +class IssueSubscriberViewSet(BaseViewSet): + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_permissions(self): + if self.action in ["subscribe", "unsubscribe", "subscription_status"]: + self.permission_classes = [ + ProjectLitePermission, + ] + else: + self.permission_classes = [ + ProjectEntityPermission, + ] + + return super(IssueSubscriberViewSet, self).get_permissions() + + 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 ( + 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) + .order_by("-created_at") + .distinct() + ) + + def list(self, request, slug, project_id, issue_id): + members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber=OuterRef("member"), + ) + ) + ) + .select_related("member") + ) + serializer = ProjectMemberLiteSerializer(members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, issue_id, subscriber_id): + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=subscriber_id, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + + def subscribe(self, request, slug, project_id, issue_id): + if IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists(): + return Response( + {"message": "User already subscribed to the issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + subscriber = IssueSubscriber.objects.create( + issue_id=issue_id, + subscriber_id=request.user.id, + project_id=project_id, + ) + serializer = IssueSubscriberSerializer(subscriber) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def unsubscribe(self, request, slug, project_id, issue_id): + issue_subscriber = IssueSubscriber.objects.get( + project=project_id, + subscriber=request.user, + workspace__slug=slug, + issue=issue_id, + ) + issue_subscriber.delete() + return Response( + status=status.HTTP_204_NO_CONTENT, + ) + + def subscription_status(self, request, slug, project_id, issue_id): + issue_subscriber = IssueSubscriber.objects.filter( + issue=issue_id, + subscriber=request.user, + workspace__slug=slug, + project=project_id, + ).exists() + return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) + + +class IssueReactionViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + return ( + 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) + .order_by("-created_at") + .distinct() + ) + + def create(self, request, slug, project_id, issue_id): + serializer = IssueReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + issue_id=issue_id, + project_id=project_id, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, reaction_code): + issue_reaction = IssueReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + issue_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentReactionViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(comment_id=self.kwargs.get("comment_id")) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + .distinct() + ) + + def create(self, request, slug, project_id, comment_id): + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + actor_id=request.user.id, + comment_id=comment_id, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, comment_id, reaction_code): + comment_reaction = CommentReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + comment_id=comment_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ + ProjectEntityPermission, + ] + + 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() + ) + + def create(self, request, slug, project_id, issue_id): + related_list = request.data.get("related_list", []) + relation = request.data.get("relation", None) + project = Project.objects.get(pk=project_id) + + issue_relation = IssueRelation.objects.bulk_create( + [ + IssueRelation( + issue_id=related_issue["issue"], + related_issue_id=related_issue["related_issue"], + relation_type=related_issue["relation_type"], + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for related_issue in related_list + ], + batch_size=10, + ignore_conflicts=True, + ) + + issue_activity.delay( + type="issue_relation.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + + if relation == "blocking": + return Response( + RelatedIssueSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + else: + return Response( + IssueRelationSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + + def destroy(self, request, slug, project_id, issue_id, pk): + issue_relation = IssueRelation.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueRelationSerializer(issue_relation).data, + cls=DjangoJSONEncoder, + ) + issue_relation.delete() + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps({"related_list": None}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueDraftViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + Issue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(is_draft=True) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + fields = [field for field in request.GET.get("fields", "").split(",") if field] + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save(is_draft=True) + + # Track the issue + issue_activity.delay( + type="issue_draft.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, pk): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + serializer = IssueSerializer(issue, data=request.data, partial=True) + + if serializer.is_valid(): + if request.data.get("is_draft") is not None and not request.data.get( + "is_draft" + ): + serializer.save(created_at=timezone.now(), updated_at=timezone.now()) + else: + serializer.save() + issue_activity.delay( + type="issue_draft.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(issue).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk=None): + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, pk=None): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + issue.delete() + issue_activity.delay( + type="issue_draft.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py new file mode 100644 index 000000000..a8a8655c3 --- /dev/null +++ b/apiserver/plane/app/views/module.py @@ -0,0 +1,524 @@ +# Python imports +import json + +# Django Imports +from django.utils import timezone +from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q +from django.core import serializers +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.serializers import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleFavoriteSerializer, + IssueStateSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Module, + ModuleIssue, + Project, + Issue, + ModuleLink, + ModuleFavorite, + IssueLink, + IssueAttachment, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters +from plane.utils.analytics_plot import burndown_plot + + +class ModuleViewSet(WebhookMixin, BaseViewSet): + model = Module + permission_classes = [ + ProjectEntityPermission, + ] + webhook_event = "module" + + def get_serializer_class(self): + return ( + ModuleWriteSerializer + if self.action in ["create", "update", "partial_update"] + else ModuleSerializer + ) + + def get_queryset(self): + + subquery = ModuleFavorite.objects.filter( + user=self.request.user, + module_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return ( + super() + .get_queryset() + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .annotate(is_favorite=Exists(subquery)) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .order_by("-is_favorite","-created_at") + ) + + def create(self, request, slug, project_id): + project = Project.objects.get(workspace__slug=slug, pk=project_id) + serializer = ModuleWriteSerializer( + data=request.data, context={"project": project} + ) + + if serializer.is_valid(): + serializer.save() + + module = Module.objects.get(pk=serializer.data["id"]) + serializer = ModuleSerializer(module) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, pk): + queryset = self.get_queryset().get(pk=pk) + + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values("first_name", "last_name", "assignee_id", "avatar", "display_name") + .annotate( + total_issues=Count( + "assignee_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "label_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data = ModuleSerializer(queryset).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, slug=slug, project_id=project_id, module_id=pk + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, pk): + module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + module_issues = list( + ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) + ) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps( + { + "module_id": str(pk), + "module_name": str(module.name), + "issues": [str(issue_id) for issue_id in module_issues], + } + ), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + module.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleIssueViewSet(WebhookMixin, BaseViewSet): + serializer_class = ModuleIssueSerializer + model = ModuleIssue + webhook_event = "module_issue" + bulk = True + + + filterset_fields = [ + "issue__labels__id", + "issue__assignees__id", + ] + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .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", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .prefetch_related("module__members") + .distinct() + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, module_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + order_by = request.GET.get("order_by", "created_at") + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.issue_objects.filter(issue_module__module_id=module_id) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_module__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .filter(**filters) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id, module_id): + 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 + ) + + module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) + + update_module_issue_activity = [] + records_to_update = [] + record_to_create = [] + + for issue in issues: + module_issue = [ + module_issue + for module_issue in module_issues + if str(module_issue.issue_id) in issues + ] + + if len(module_issue): + if module_issue[0].module_id != module_id: + update_module_issue_activity.append( + { + "old_module_id": str(module_issue[0].module_id), + "new_module_id": str(module_id), + "issue_id": str(module_issue[0].issue_id), + } + ) + module_issue[0].module_id = module_id + records_to_update.append(module_issue[0]) + else: + record_to_create.append( + ModuleIssue( + module=module, + issue_id=issue, + project_id=project_id, + workspace=module.workspace, + created_by=request.user, + updated_by=request.user, + ) + ) + + ModuleIssue.objects.bulk_create( + record_to_create, + batch_size=10, + ignore_conflicts=True, + ) + + ModuleIssue.objects.bulk_update( + records_to_update, + ["module"], + batch_size=10, + ) + + # Capture Issue Activity + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"modules_list": issues}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_module_issues": update_module_issue_activity, + "created_module_issues": serializers.serialize( + "json", record_to_create + ), + } + ), + epoch=int(timezone.now().timestamp()), + ) + + return Response( + ModuleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, module_id, pk): + module_issue = ModuleIssue.objects.get( + workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk + ) + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps( + { + "module_id": str(module_id), + "issues": [str(module_issue.issue_id)], + } + ), + actor_id=str(request.user.id), + issue_id=str(module_issue.issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + module_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleLinkViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + model = ModuleLink + serializer_class = ModuleLinkSerializer + + 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 ( + 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) + .order_by("-created_at") + .distinct() + ) + + +class ModuleFavoriteViewSet(BaseViewSet): + serializer_class = ModuleFavoriteSerializer + model = ModuleFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("module") + ) + + def create(self, request, slug, project_id): + serializer = ModuleFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, module_id): + module_favorite = ModuleFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + module_id=module_id, + ) + module_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/app/views/notification.py similarity index 96% rename from apiserver/plane/api/views/notification.py rename to apiserver/plane/app/views/notification.py index 978c01bac..9494ea86c 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/app/views/notification.py @@ -5,7 +5,6 @@ from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response -from sentry_sdk import capture_exception from plane.utils.paginator import BasePaginator # Module imports @@ -17,7 +16,7 @@ from plane.db.models import ( Issue, WorkspaceMember, ) -from plane.api.serializers import NotificationSerializer +from plane.app.serializers import NotificationSerializer class NotificationViewSet(BaseViewSet, BasePaginator): @@ -85,7 +84,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Created issues if type == "created": if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15 + workspace__slug=slug, + member=request.user, + role__lt=15, + is_active=True, ).exists(): notifications = Notification.objects.none() else: @@ -255,7 +257,10 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # Created issues if type == "created": if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15 + workspace__slug=slug, + member=request.user, + role__lt=15, + is_active=True, ).exists(): notifications = Notification.objects.none() else: diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/app/views/oauth.py similarity index 52% rename from apiserver/plane/api/views/oauth.py rename to apiserver/plane/app/views/oauth.py index f0ea9acc9..e12cba2ae 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -20,9 +20,18 @@ 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 plane.db.models import ( + SocialLoginConnection, + User, + WorkspaceMemberInvite, + WorkspaceMember, + ProjectMemberInvite, + ProjectMember, +) +from plane.bgtasks.event_tracking_task import auth_events from .base import BaseAPIView +from plane.license.models import Instance +from plane.license.utils.instance_value import get_configuration_value def get_tokens_for_user(user): @@ -126,10 +135,37 @@ class OauthEndpoint(BaseAPIView): def post(self, request): try: + # Check if instance is registered or not + instance = Instance.objects.first() + if instance is None and not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + medium = request.data.get("medium", False) id_token = request.data.get("credential", False) client_id = request.data.get("clientId", False) + GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + ] + ) + + if not GOOGLE_CLIENT_ID or not GITHUB_CLIENT_ID: + return Response( + {"error": "Github or Google login is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not medium or not id_token: return Response( { @@ -167,16 +203,7 @@ class OauthEndpoint(BaseAPIView): 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.is_active = True user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") @@ -185,12 +212,62 @@ class OauthEndpoint(BaseAPIView): user.is_email_verified = email_verified user.save() - access_token, refresh_token = get_tokens_for_user(user) + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - } + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() SocialLoginConnection.objects.update_or_create( medium=medium, @@ -201,30 +278,47 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": f"oauth-{medium}", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_IN", - }, - ) + + # Send event + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium=medium.upper(), + first_time=False, + ) + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } return Response(data, status=status.HTTP_200_OK) except User.DoesNotExist: - ## Signup Case + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "0"), + } + ] + ) + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) username = uuid.uuid4().hex @@ -240,7 +334,7 @@ class OauthEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - user = User( + user = User.objects.create( username=username, email=email, mobile_number=mobile_number, @@ -251,7 +345,6 @@ class OauthEndpoint(BaseAPIView): ) 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") @@ -260,31 +353,73 @@ class OauthEndpoint(BaseAPIView): 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, - } - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": f"oauth-{medium}", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_UP", - }, - ) + # Check if user has any accepted invites for workspace and add them to workspace + workspace_member_invites = WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=workspace_member_invite.workspace_id, + member=user, + role=workspace_member_invite.role, + ) + for workspace_member_invite in workspace_member_invites + ], + ignore_conflicts=True, + ) + + # Check if user has any project invites + project_member_invites = ProjectMemberInvite.objects.filter( + email=user.email, accepted=True + ) + + # Add user to workspace + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + + # Now add the users to project + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace_id=project_member_invite.workspace_id, + role=project_member_invite.role + if project_member_invite.role in [5, 10, 15] + else 15, + member=user, + created_by_id=project_member_invite.created_by_id, + ) + for project_member_invite in project_member_invites + ], + ignore_conflicts=True, + ) + # Delete all the invites + workspace_member_invites.delete() + project_member_invites.delete() + + # Send event + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium=medium.upper(), + first_time=True, + ) SocialLoginConnection.objects.update_or_create( medium=medium, @@ -295,4 +430,11 @@ class OauthEndpoint(BaseAPIView): "last_login_at": timezone.now(), }, ) + + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + return Response(data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py new file mode 100644 index 000000000..9bd1f1dd4 --- /dev/null +++ b/apiserver/plane/app/views/page.py @@ -0,0 +1,344 @@ +# Python imports +from datetime import timedelta, date, datetime + +# Django imports +from django.db import connection +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Page, + PageFavorite, + Issue, + IssueAssignee, + IssueActivity, + PageLog, + ProjectMember, +) +from plane.app.serializers import ( + PageSerializer, + PageFavoriteSerializer, + PageLogSerializer, + IssueLiteSerializer, + SubPageSerializer, +) + + +def unarchive_archive_page_and_descendants(page_id, archived_at): + # Your SQL query + sql = """ + WITH RECURSIVE descendants AS ( + SELECT id FROM pages WHERE id = %s + UNION ALL + SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id + ) + UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); + """ + + # Execute the SQL query + with connection.cursor() as cursor: + cursor.execute(sql, [page_id, archived_at]) + + +class PageViewSet(BaseViewSet): + serializer_class = PageSerializer + model = Page + permission_classes = [ + ProjectEntityPermission, + ] + search_fields = [ + "name", + ] + + def get_queryset(self): + subquery = PageFavorite.objects.filter( + user=self.request.user, + page_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + 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(parent__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .order_by(self.request.GET.get("order_by", "-created_at")) + .prefetch_related("labels") + .order_by("-is_favorite", "-created_at") + .distinct() + ) + + def create(self, request, slug, project_id): + serializer = PageSerializer( + data=request.data, + context={"project_id": project_id, "owned_by_id": request.user.id}, + ) + + 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) + + def partial_update(self, request, slug, project_id, pk): + try: + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + + if page.is_locked: + return Response( + {"error": "Page is locked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + parent = request.data.get("parent", None) + if parent: + _ = Page.objects.get( + pk=parent, workspace__slug=slug, project_id=project_id + ) + + # Only update access if the page owner is the requesting user + if ( + page.access != request.data.get("access", page.access) + and page.owned_by_id != request.user.id + ): + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = PageSerializer(page, data=request.data, 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 Page.DoesNotExist: + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + def lock(self, request, slug, project_id, page_id): + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, project_id=project_id + ).first() + + page.is_locked = True + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def unlock(self, request, slug, project_id, page_id): + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, project_id=project_id + ).first() + + page.is_locked = False + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + def list(self, request, slug, project_id): + queryset = self.get_queryset().filter(archived_at__isnull=True) + return Response( + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + + def archive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + + # only the owner and admin can archive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, member=request.user, is_active=True, role__gt=20 + ).exists() + or request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner and admin can archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + unarchive_archive_page_and_descendants(page_id, datetime.now()) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def unarchive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + + # only the owner and admin can un archive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, member=request.user, is_active=True, role__gt=20 + ).exists() + or request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner and admin can un archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # if parent page is archived then the page will be un archived breaking the hierarchy + if page.parent_id and page.parent.archived_at: + page.parent = None + page.save(update_fields=["parent"]) + + unarchive_archive_page_and_descendants(page_id, None) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def archive_list(self, request, slug, project_id): + pages = Page.objects.filter( + project_id=project_id, + workspace__slug=slug, + ).filter(archived_at__isnull=False) + + return Response( + PageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) + + def destroy(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + + # only the owner and admin can delete the page + if ( + ProjectMember.objects.filter( + project_id=project_id, member=request.user, is_active=True, role__gt=20 + ).exists() + or request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner and admin can delete the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # remove parent from all the children + _ = Page.objects.filter( + parent_id=pk, project_id=project_id, workspace__slug=slug + ).update(parent=None) + + page.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageFavoriteViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = PageFavoriteSerializer + model = PageFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(archived_at__isnull=True) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("page", "page__owned_by") + ) + + def create(self, request, slug, project_id): + serializer = PageFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, page_id): + page_favorite = PageFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + page_id=page_id, + ) + page_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageLogEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = PageLogSerializer + model = PageLog + + def post(self, request, slug, project_id, page_id): + serializer = PageLogSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, page_id=page_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, page_id, transaction): + page_transaction = PageLog.objects.get( + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + transaction=transaction, + ) + serializer = PageLogSerializer( + page_transaction, data=request.data, 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) + + def delete(self, request, slug, project_id, page_id, transaction): + transaction = PageLog.objects.get( + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + transaction=transaction, + ) + # Delete the transaction object + transaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class SubPagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + @method_decorator(gzip_page) + def get(self, request, slug, project_id, page_id): + pages = ( + PageLog.objects.filter( + page_id=page_id, + project_id=project_id, + workspace__slug=slug, + entity_name__in=["forward_link", "back_link"], + ) + .select_related("project") + .select_related("workspace") + ) + return Response( + SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py new file mode 100644 index 000000000..2ed82e7e9 --- /dev/null +++ b/apiserver/plane/app/views/project.py @@ -0,0 +1,1069 @@ +# Python imports +import jwt +import boto3 +from datetime import datetime + +# Django imports +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db.models import ( + Prefetch, + Q, + Exists, + OuterRef, + F, + Func, + Subquery, +) +from django.core.validators import validate_email +from django.conf import settings +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework import serializers +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.serializers import ( + ProjectSerializer, + ProjectListSerializer, + ProjectMemberSerializer, + ProjectDetailSerializer, + ProjectMemberInviteSerializer, + ProjectFavoriteSerializer, + ProjectDeployBoardSerializer, + ProjectMemberAdminSerializer, +) + +from plane.app.permissions import ( + WorkspaceUserPermission, + ProjectBasePermission, + ProjectMemberPermission, +) + +from plane.db.models import ( + Project, + ProjectMember, + Workspace, + ProjectMemberInvite, + User, + WorkspaceMember, + State, + TeamMember, + ProjectFavorite, + ProjectIdentifier, + Module, + Cycle, + Inbox, + ProjectDeployBoard, + IssueProperty, +) + +from plane.bgtasks.project_invitation_task import project_invitation + + +class ProjectViewSet(WebhookMixin, BaseViewSet): + serializer_class = ProjectSerializer + model = Project + webhook_event = "project" + + permission_classes = [ + ProjectBasePermission, + ] + + def get_serializer_class(self, *args, **kwargs): + if self.action in ["update", "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", "default_assignee", "project_lead" + ) + .annotate( + is_favorite=Exists( + ProjectFavorite.objects.filter( + user=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate( + total_members=ProjectMember.objects.filter( + project_id=OuterRef("id"), + member__is_bot=False, + is_active=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + is_deployed=Exists( + ProjectDeployBoard.objects.filter( + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .distinct() + ) + + def list(self, request, slug): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + + sort_order_query = ProjectMember.objects.filter( + member=request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + projects = ( + self.get_queryset() + .annotate(sort_order=Subquery(sort_order_query)) + .prefetch_related( + Prefetch( + "project_projectmember", + queryset=ProjectMember.objects.filter( + workspace__slug=slug, + is_active=True, + ).select_related("member"), + to_attr="members_list", + ) + ) + .order_by("sort_order", "name") + ) + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + request=request, + queryset=(projects), + on_results=lambda projects: ProjectListSerializer( + projects, many=True + ).data, + ) + + return Response( + ProjectListSerializer( + projects, many=True, fields=fields if fields else None + ).data + ) + + 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 + project_member = ProjectMember.objects.create( + project_id=serializer.data["id"], member=request.user, role=20 + ) + # Also create the issue property for the user + _ = IssueProperty.objects.create( + project_id=serializer.data["id"], + user=request.user, + ) + + if serializer.data["project_lead"] is not None and str( + serializer.data["project_lead"] + ) != str(request.user.id): + ProjectMember.objects.create( + project_id=serializer.data["id"], + member_id=serializer.data["project_lead"], + role=20, + ) + # Also create the issue property for the user + IssueProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) + + # Default states + states = [ + { + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#EF4444", + "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"], + default=state.get("default", False), + created_by=request.user, + ) + for state in states + ] + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) + 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( + {"name": "The project name 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, + ) + + 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() + if serializer.data["inbox_view"]: + Inbox.objects.get_or_create( + name=f"{project.name} Inbox", project=project, is_default=True + ) + + # Create the triage state in Backlog group + State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=pk, + color="#ff7700", + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) + 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, Workspace.DoesNotExist): + 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, + ) + + +class ProjectInvitationsViewset(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") + .select_related("workspace", "workspace__owner") + ) + + def create(self, request, slug, project_id): + emails = request.data.get("emails", []) + + # Check if email is provided + if not emails: + return Response( + {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST + ) + + requesting_user = ProjectMember.objects.get( + workspace__slug=slug, project_id=project_id, member_id=request.user.id + ) + + # Check if any invited user has an higher role + if len( + [ + email + for email in emails + if int(email.get("role", 10)) > requesting_user.role + ] + ): + return Response( + {"error": "You cannot invite a user with higher role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + project_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + project_invitations.append( + ProjectMemberInvite( + email=email.get("email").strip().lower(), + project_id=project_id, + workspace_id=workspace.id, + token=jwt.encode( + { + "email": email, + "timestamp": datetime.now().timestamp(), + }, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 10), + created_by=request.user, + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create workspace member invite + project_invitations = ProjectMemberInvite.objects.bulk_create( + project_invitations, batch_size=10, ignore_conflicts=True + ) + current_site = request.META.get("HTTP_ORIGIN") + + # Send invitations + for invitation in project_invitations: + project_invitations.delay( + invitation.email, + project_id, + invitation.token, + current_site, + request.user.email, + ) + + return Response( + { + "message": "Email sent successfully", + }, + status=status.HTTP_200_OK, + ) + + +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", "workspace__owner", "project") + ) + + def create(self, request, slug): + project_ids = request.data.get("project_ids", []) + + # Get the workspace user role + workspace_member = WorkspaceMember.objects.get( + member=request.user, + workspace__slug=slug, + is_active=True, + ) + + workspace_role = workspace_member.role + workspace = workspace_member.workspace + + # If the user was already part of workspace + _ = ProjectMember.objects.filter( + workspace__slug=slug, + project_id__in=project_ids, + member=request.user, + ).update(is_active=True) + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=project_id, + member=request.user, + role=15 if workspace_role >= 15 else 10, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=project_id, + user=request.user, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + + return Response( + {"message": "Projects joined successfully"}, + status=status.HTTP_201_CREATED, + ) + + +class ProjectJoinEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request, slug, project_id, pk): + project_invite = ProjectMemberInvite.objects.get( + pk=pk, + project_id=project_id, + workspace__slug=slug, + ) + + email = request.data.get("email", "") + + if email == "" or project_invite.email != email: + return Response( + {"error": "You do not have permission to join the project"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if project_invite.responded_at is None: + project_invite.accepted = request.data.get("accepted", False) + project_invite.responded_at = timezone.now() + project_invite.save() + + if project_invite.accepted: + # Check if the user account exists + user = User.objects.filter(email=email).first() + + # Check if user is a part of workspace + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, member=user + ).first() + # Add him to workspace + if workspace_member is None: + _ = WorkspaceMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=15 if project_invite.role >= 15 else project_invite.role, + ) + else: + # Else make him active + workspace_member.is_active = True + workspace_member.save() + + # Check if the user was already a member of project then activate the user + project_member = ProjectMember.objects.filter( + workspace_id=project_invite.workspace_id, member=user + ).first() + if project_member is None: + # Create a Project Member + _ = ProjectMember.objects.create( + workspace_id=project_invite.workspace_id, + member=user, + role=project_invite.role, + ) + else: + project_member.is_active = True + project_member.role = project_member.role + project_member.save() + + return Response( + {"message": "Project Invitation Accepted"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"message": "Project 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, + ) + + def get(self, request, slug, project_id, pk): + project_invitation = ProjectMemberInvite.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = ProjectMemberInviteSerializer(project_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProjectMemberViewSet(BaseViewSet): + serializer_class = ProjectMemberAdminSerializer + model = ProjectMember + permission_classes = [ + ProjectMemberPermission, + ] + + search_fields = [ + "member__display_name", + "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")) + .filter(member__is_bot=False) + .filter() + .select_related("project") + .select_related("member") + .select_related("workspace", "workspace__owner") + ) + + def create(self, request, slug, project_id): + members = request.data.get("members", []) + + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + if not len(members): + return Response( + {"error": "Atleast one member is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_project_members = [] + bulk_issue_props = [] + + project_members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + member_id__in=[member.get("member_id") for member in members], + ) + .values("member_id", "sort_order") + .order_by("sort_order") + ) + + for member in members: + sort_order = [ + project_member.get("sort_order") + for project_member in project_members + if str(project_member.get("member_id")) == str(member.get("member_id")) + ] + bulk_project_members.append( + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 10), + project_id=project_id, + workspace_id=project.workspace_id, + sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, + ) + ) + bulk_issue_props.append( + IssueProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) + + project_members = ProjectMember.objects.bulk_create( + bulk_project_members, + batch_size=10, + ignore_conflicts=True, + ) + + _ = IssueProperty.objects.bulk_create( + bulk_issue_props, batch_size=10, ignore_conflicts=True + ) + + serializer = ProjectMemberSerializer(project_members, many=True) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def list(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + member=request.user, + workspace__slug=slug, + project_id=project_id, + is_active=True, + ) + + project_members = ProjectMember.objects.filter( + project_id=project_id, + workspace__slug=slug, + member__is_bot=False, + is_active=True, + ).select_related("project", "member", "workspace") + + if project_member.role > 10: + serializer = ProjectMemberAdminSerializer(project_members, many=True) + else: + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + is_active=True, + ) + if request.user.id == project_member.member_id: + return Response( + {"error": "You cannot update your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check while updating user roles + requested_project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + if ( + "role" in request.data + and int(request.data.get("role", project_member.role)) + > requested_project_member.role + ): + return Response( + {"error": "You cannot update a role that is higher than your own role"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectMemberSerializer( + project_member, data=request.data, 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) + + def destroy(self, request, slug, project_id, pk): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + member__is_bot=False, + is_active=True, + ) + # check requesting user role + requesting_project_member = ProjectMember.objects.get( + workspace__slug=slug, + member=request.user, + project_id=project_id, + is_active=True, + ) + # User cannot remove himself + if str(project_member.id) == str(requesting_project_member.id): + return Response( + { + "error": "You cannot remove yourself from the workspace. Please use leave workspace" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # User cannot deactivate higher role + if requesting_project_member.role < project_member.role: + return Response( + {"error": "You cannot remove a user having role higher than you"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def leave(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + workspace__slug=slug, + project_id=project_id, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the project + if ( + project_member.role == 20 + and not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Deactivate the user + project_member.is_active = False + project_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AddTeamToProjectEndpoint(BaseAPIView): + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + 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 = [] + issue_props = [] + for member in team_members: + project_members.append( + ProjectMember( + project_id=project_id, + member_id=member, + workspace=workspace, + created_by=request.user, + ) + ) + issue_props.append( + IssueProperty( + project_id=project_id, + user_id=member, + workspace=workspace, + created_by=request.user, + ) + ) + + ProjectMember.objects.bulk_create( + project_members, batch_size=10, ignore_conflicts=True + ) + + _ = IssueProperty.objects.bulk_create( + issue_props, batch_size=10, ignore_conflicts=True + ) + + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class ProjectIdentifierEndpoint(BaseAPIView): + permission_classes = [ + ProjectBasePermission, + ] + + def get(self, request, slug): + 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, + ) + + def delete(self, request, slug): + 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, + ) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter( + member=request.user, + project=project, + is_active=True, + ).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 + preferences = project_member.preferences + sort_order = project_member.sort_order + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get("default_props", default_props) + project_member.preferences = request.data.get("preferences", preferences) + project_member.sort_order = request.data.get("sort_order", sort_order) + + project_member.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectMemberUserEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + project_id=project_id, + workspace__slug=slug, + member=request.user, + is_active=True, + ) + serializer = ProjectMemberSerializer(project_member) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProjectFavoritesViewSet(BaseViewSet): + serializer_class = ProjectFavoriteSerializer + model = ProjectFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related( + "project", "project__project_lead", "project__default_assignee" + ) + .select_related("workspace", "workspace__owner") + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, slug): + serializer = ProjectFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id): + project_favorite = ProjectFavorite.objects.get( + project=project_id, user=request.user, workspace__slug=slug + ) + project_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + files = [] + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + + +class ProjectDeployBoardViewSet(BaseViewSet): + permission_classes = [ + ProjectMemberPermission, + ] + serializer_class = ProjectDeployBoardSerializer + model = ProjectDeployBoard + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .select_related("project") + ) + + def create(self, request, slug, project_id): + comments = request.data.get("comments", False) + reactions = request.data.get("reactions", False) + inbox = request.data.get("inbox", None) + votes = request.data.get("votes", False) + views = request.data.get( + "views", + { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + }, + ) + + project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( + anchor=f"{slug}/{project_id}", + project_id=project_id, + ) + project_deploy_board.comments = comments + project_deploy_board.reactions = reactions + project_deploy_board.inbox = inbox + project_deploy_board.votes = votes + project_deploy_board.views = views + + project_deploy_board.save() + + serializer = ProjectDeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserProjectRolesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceUserPermission, + ] + + def get(self, request, slug): + project_members = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=request.user.id, + ).values("project_id", "role") + + project_members = { + str(member["project_id"]): member["role"] for member in project_members + } + return Response(project_members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/app/views/search.py similarity index 99% rename from apiserver/plane/api/views/search.py rename to apiserver/plane/app/views/search.py index ff7431543..ac560643a 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -7,7 +7,6 @@ from django.db.models import Q # Third party imports from rest_framework import status from rest_framework.response import Response -from sentry_sdk import capture_exception # Module imports from .base import BaseAPIView diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py new file mode 100644 index 000000000..f7226ba6e --- /dev/null +++ b/apiserver/plane/app/views/state.py @@ -0,0 +1,92 @@ +# Python imports +from itertools import groupby + +# Django imports +from django.db.models import Q + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseViewSet +from plane.app.serializers import StateSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import State, Issue + + +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) + .filter(~Q(name="Triage")) + .select_related("project") + .select_related("workspace") + .distinct() + ) + + def create(self, request, slug, project_id): + serializer = StateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def list(self, request, slug, project_id): + states = StateSerializer(self.get_queryset(), many=True).data + grouped = request.GET.get("grouped", False) + if grouped == "true": + state_dict = {} + for key, value in groupby( + sorted(states, key=lambda state: state["group"]), + lambda state: state.get("group"), + ): + state_dict[str(key)] = list(value) + return Response(state_dict, status=status.HTTP_200_OK) + return Response(states, status=status.HTTP_200_OK) + + def mark_as_default(self, request, slug, project_id, pk): + # Select all the states which are marked as default + _ = State.objects.filter( + workspace__slug=slug, project_id=project_id, default=True + ).update(default=False) + _ = State.objects.filter( + workspace__slug=slug, project_id=project_id, pk=pk + ).update(default=True) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, slug, project_id, pk): + state = State.objects.get( + ~Q(name="Triage"), + pk=pk, + project_id=project_id, + workspace__slug=slug, + ) + + if state.default: + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) + + # Check for any issues in the state + issue_exist = Issue.issue_objects.filter(state=pk).exists() + + if issue_exist: + return Response( + {"error": "The state is not empty, only empty states can be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + state.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py new file mode 100644 index 000000000..85874f460 --- /dev/null +++ b/apiserver/plane/app/views/user.py @@ -0,0 +1,157 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +# Module imports +from plane.app.serializers import ( + UserSerializer, + IssueActivitySerializer, + UserMeSerializer, + UserMeSettingsSerializer, +) + +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember +from plane.license.models import Instance, InstanceAdmin +from plane.utils.paginator import BasePaginator + + +from django.db.models import Q, F, Count, Case, When, IntegerField + + +class UserEndpoint(BaseViewSet): + serializer_class = UserSerializer + model = User + + def get_object(self): + return self.request.user + + def retrieve(self, request): + serialized_data = UserMeSerializer(request.user).data + return Response( + serialized_data, + status=status.HTTP_200_OK, + ) + + def retrieve_user_settings(self, request): + serialized_data = UserMeSettingsSerializer(request.user).data + return Response(serialized_data, status=status.HTTP_200_OK) + + def retrieve_instance_admin(self, request): + instance = Instance.objects.first() + is_admin = InstanceAdmin.objects.filter( + instance=instance, user=request.user + ).exists() + return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) + + def deactivate(self, request): + # Check all workspace user is active + user = self.get_object() + + projects_to_deactivate = [] + workspaces_to_deactivate = [] + + projects = ProjectMember.objects.filter( + member=request.user, is_active=True + ).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for project in projects: + if project.other_admin_exists > 0 or (project.total_members == 1): + project.is_active = False + projects_to_deactivate.append(project) + else: + return Response( + { + "error": "You cannot deactivate account as you are the only admin in some projects." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspaces = WorkspaceMember.objects.filter( + member=request.user, is_active=True + ).annotate( + other_admin_exists=Count( + Case( + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + default=0, + output_field=IntegerField(), + ) + ), + total_members=Count("id"), + ) + + for workspace in workspaces: + if workspace.other_admin_exists > 0 or (workspace.total_members == 1): + workspace.is_active = False + workspaces_to_deactivate.append(workspace) + else: + return Response( + { + "error": "You cannot deactivate account as you are the only admin in some workspaces." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectMember.objects.bulk_update( + projects_to_deactivate, ["is_active"], batch_size=100 + ) + + WorkspaceMember.objects.bulk_update( + workspaces_to_deactivate, ["is_active"], batch_size=100 + ) + + # Deactivate the user + user.is_active = False + user.last_workspace_id = None + user.is_tour_completed = False + user.is_onboarded = False + user.onboarding_step = { + "workspace_join": False, + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + } + user.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UpdateUserOnBoardedEndpoint(BaseAPIView): + def patch(self, request): + user = User.objects.get(pk=request.user.id, is_active=True) + user.is_onboarded = request.data.get("is_onboarded", False) + user.save() + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) + + +class UpdateUserTourCompletedEndpoint(BaseAPIView): + def patch(self, request): + user = User.objects.get(pk=request.user.id, is_active=True) + user.is_tour_completed = request.data.get("is_tour_completed", False) + user.save() + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) + + +class UserActivityEndpoint(BaseAPIView, BasePaginator): + def get(self, request): + queryset = IssueActivity.objects.filter(actor=request.user).select_related( + "actor", "workspace", "issue", "project" + ) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer( + issue_activities, many=True + ).data, + ) + diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/app/views/view.py similarity index 89% rename from apiserver/plane/api/views/view.py rename to apiserver/plane/app/views/view.py index f58f320b7..eb76407b7 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -18,17 +18,16 @@ from django.db.models import Prefetch, OuterRef, Exists # 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 ( +from plane.app.serializers import ( GlobalViewSerializer, IssueViewSerializer, IssueLiteSerializer, IssueViewFavoriteSerializer, ) -from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission +from plane.app.permissions import WorkspaceEntityPermission, ProjectEntityPermission from plane.db.models import ( Workspace, GlobalView, @@ -96,6 +95,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") + fields = [field for field in request.GET.get("fields", "").split(",") if field] # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] @@ -178,25 +178,13 @@ class GlobalViewIssuesViewSet(BaseViewSet): ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True).data - ## Grouping the results - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - if sub_group_by and sub_group_by == group_by: - return Response( - {"error": "Group by and sub group by cannot be same"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if group_by: - grouped_results = group_results(issues, group_by, sub_group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response(issues, status=status.HTTP_200_OK) + issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response( + issue_dict, + status=status.HTTP_200_OK, + ) class IssueViewViewSet(BaseViewSet): @@ -258,4 +246,4 @@ class IssueViewFavoriteViewSet(BaseViewSet): view_id=view_id, ) view_favourite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook.py new file mode 100644 index 000000000..48608d583 --- /dev/null +++ b/apiserver/plane/app/views/webhook.py @@ -0,0 +1,132 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import Webhook, WebhookLog, Workspace +from plane.db.models.webhook import generate_token +from .base import BaseAPIView +from plane.app.permissions import WorkspaceOwnerPermission +from plane.app.serializers import WebhookSerializer, WebhookLogSerializer + + +class WebhookEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + try: + serializer = WebhookSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id) + 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": "URL already exists for the workspace"}, + status=status.HTTP_410_GONE, + ) + raise IntegrityError + + def get(self, request, slug, pk=None): + if pk == None: + webhooks = Webhook.objects.filter(workspace__slug=slug) + serializer = WebhookSerializer( + webhooks, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + data=request.data, + context={request: request}, + partial=True, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + 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) + + def delete(self, request, slug, pk): + webhook = Webhook.objects.get(pk=pk, workspace__slug=slug) + webhook.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WebhookSecretRegenerateEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + webhook.secret_key = generate_token() + webhook.save() + serializer = WebhookSerializer(webhook) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WebhookLogsEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def get(self, request, slug, webhook_id): + webhook_logs = WebhookLog.objects.filter( + workspace__slug=slug, webhook_id=webhook_id + ) + serializer = WebhookLogSerializer(webhook_logs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/app/views/workspace.py similarity index 81% rename from apiserver/plane/api/views/workspace.py rename to apiserver/plane/app/views/workspace.py index c53fbf126..ed72dbcf1 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -2,7 +2,6 @@ import jwt from datetime import date, datetime from dateutil.relativedelta import relativedelta -from uuid import uuid4 # Django imports from django.db import IntegrityError @@ -26,16 +25,14 @@ from django.db.models import ( ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField -from django.contrib.auth.hashers import make_password # 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 ( +from plane.app.serializers import ( WorkSpaceSerializer, WorkSpaceMemberSerializer, TeamSerializer, @@ -48,7 +45,7 @@ from plane.api.serializers import ( WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, ) -from plane.api.views.base import BaseAPIView +from plane.app.views.base import BaseAPIView from . import BaseViewSet from plane.db.models import ( User, @@ -59,14 +56,6 @@ from plane.db.models import ( IssueActivity, Issue, WorkspaceTheme, - IssueAssignee, - ProjectFavorite, - CycleFavorite, - ModuleMember, - ModuleFavorite, - PageFavorite, - Page, - IssueViewFavorite, IssueLink, IssueAttachment, IssueSubscriber, @@ -76,7 +65,7 @@ from plane.db.models import ( CycleIssue, IssueReaction, ) -from plane.api.permissions import ( +from plane.app.permissions import ( WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, @@ -84,8 +73,7 @@ from plane.api.permissions import ( ) from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.utils.issue_filters import issue_filters -from plane.utils.grouper import group_results - +from plane.bgtasks.event_tracking_task import workspace_invite_event class WorkSpaceViewSet(BaseViewSet): model = Workspace @@ -106,7 +94,9 @@ class WorkSpaceViewSet(BaseViewSet): def get_queryset(self): member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False + workspace=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -122,7 +112,10 @@ class WorkSpaceViewSet(BaseViewSet): return ( self.filter_queryset(super().get_queryset().select_related("owner")) .order_by("name") - .filter(workspace_member__member=self.request.user) + .filter( + workspace_member__member=self.request.user, + workspace_member__is_active=True, + ) .annotate(total_members=member_count) .annotate(total_issues=issue_count) .select_related("owner") @@ -181,7 +174,9 @@ class UserWorkSpacesEndpoint(BaseAPIView): def get(self, request): member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False + workspace=OuterRef("id"), + member__is_bot=False, + is_active=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -196,17 +191,21 @@ class UserWorkSpacesEndpoint(BaseAPIView): ) workspace = ( - ( - Workspace.objects.prefetch_related( - Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) + Workspace.objects.prefetch_related( + Prefetch( + "workspace_member", + queryset=WorkspaceMember.objects.filter( + member=request.user, is_active=True + ), ) - .filter( - workspace_member__member=request.user, - ) - .select_related("owner") ) + .select_related("owner") .annotate(total_members=member_count) .annotate(total_issues=issue_count) + .filter( + workspace_member__member=request.user, workspace_member__is_active=True + ) + .distinct() ) serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) @@ -227,23 +226,40 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): return Response({"status": not workspace}, status=status.HTTP_200_OK) -class InviteWorkspaceEndpoint(BaseAPIView): +class WorkspaceInvitationsViewset(BaseViewSet): + """Endpoint for creating, listing and deleting workspaces""" + + serializer_class = WorkSpaceMemberInviteSerializer + model = WorkspaceMemberInvite + permission_classes = [ WorkSpaceAdminPermission, ] - def post(self, request, slug): - emails = request.data.get("emails", False) + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "workspace__owner", "created_by") + ) + + def create(self, request, slug): + emails = request.data.get("emails", []) # Check if email is provided - if not emails or not len(emails): + if not emails: return Response( {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST ) - # check for role level + # check for role level of the requesting user requesting_user = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) + + # Check if any invited user has an higher role if len( [ email @@ -256,15 +272,17 @@ class InviteWorkspaceEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Get the workspace object workspace = Workspace.objects.get(slug=slug) # Check if user is already a member of workspace workspace_members = WorkspaceMember.objects.filter( workspace_id=workspace.id, member__email__in=[email.get("email") for email in emails], + is_active=True, ).select_related("member", "workspace", "workspace__owner") - if len(workspace_members): + if workspace_members: return Response( { "error": "Some users are already member of workspace", @@ -302,35 +320,20 @@ class InviteWorkspaceEndpoint(BaseAPIView): }, status=status.HTTP_400_BAD_REQUEST, ) - WorkspaceMemberInvite.objects.bulk_create( + # Create workspace member invite + workspace_invitations = WorkspaceMemberInvite.objects.bulk_create( workspace_invitations, batch_size=10, ignore_conflicts=True ) - workspace_invitations = WorkspaceMemberInvite.objects.filter( - email__in=[email.get("email") for email in emails] - ).select_related("workspace") - - # create the user if signup is disabled - if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: - _ = User.objects.bulk_create( - [ - User( - username=str(uuid4().hex), - email=invitation.email, - password=make_password(uuid4().hex), - is_password_autoset=True, - ) - for invitation in workspace_invitations - ], - batch_size=100, - ) + current_site = request.META.get("HTTP_ORIGIN") + # Send invitations for invitation in workspace_invitations: workspace_invitation.delay( invitation.email, workspace.id, invitation.token, - settings.WEB_URL, + current_site, request.user.email, ) @@ -341,11 +344,19 @@ class InviteWorkspaceEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) + def destroy(self, request, slug, pk): + workspace_member_invite = WorkspaceMemberInvite.objects.get( + pk=pk, workspace__slug=slug + ) + workspace_member_invite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) -class JoinWorkspaceEndpoint(BaseAPIView): + +class WorkspaceJoinEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] + """Invitation response endpoint the user can respond to the invitation""" def post(self, request, slug, pk): workspace_invite = WorkspaceMemberInvite.objects.get( @@ -354,12 +365,14 @@ class JoinWorkspaceEndpoint(BaseAPIView): email = request.data.get("email", "") + # Check the 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 already responded then return error if workspace_invite.responded_at is None: workspace_invite.accepted = request.data.get("accepted", False) workspace_invite.responded_at = timezone.now() @@ -371,23 +384,45 @@ class JoinWorkspaceEndpoint(BaseAPIView): # If the user is present then create the workspace member if user is not None: - WorkspaceMember.objects.create( - workspace=workspace_invite.workspace, - member=user, - role=workspace_invite.role, - ) + # Check if the user was already a member of workspace then activate the user + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace_invite.workspace, member=user + ).first() + if workspace_member is not None: + workspace_member.is_active = True + workspace_member.role = workspace_invite.role + workspace_member.save() + else: + # Create a Workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_invite.workspace, + member=user, + role=workspace_invite.role, + ) + # Set the user last_workspace_id to the accepted workspace user.last_workspace_id = workspace_invite.workspace.id user.save() # Delete the invitation workspace_invite.delete() + + # Send event + workspace_invite_event.delay( + user=user.id if user is not None else None, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="MEMBER_ACCEPTED", + accepted_from="EMAIL", + ) return Response( {"message": "Workspace Invitation Accepted"}, status=status.HTTP_200_OK, ) + # Workspace invitation rejected return Response( {"message": "Workspace Invitation was not accepted"}, status=status.HTTP_200_OK, @@ -398,37 +433,15 @@ class JoinWorkspaceEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - -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", "workspace__owner", "created_by") + def get(self, request, slug, pk): + workspace_invitation = WorkspaceMemberInvite.objects.get( + workspace__slug=slug, pk=pk ) - - def destroy(self, request, slug, pk): - workspace_member_invite = WorkspaceMemberInvite.objects.get( - pk=pk, workspace__slug=slug - ) - # delete the user if signup is disabled - if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: - user = User.objects.filter(email=workspace_member_invite.email).first() - if user is not None: - user.delete() - workspace_member_invite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + serializer = WorkSpaceMemberInviteSerializer(workspace_invitation) + return Response(serializer.data, status=status.HTTP_200_OK) -class UserWorkspaceInvitationsEndpoint(BaseViewSet): +class UserWorkspaceInvitationsViewSet(BaseViewSet): serializer_class = WorkSpaceMemberInviteSerializer model = WorkspaceMemberInvite @@ -442,9 +455,19 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet): ) def create(self, request): - invitations = request.data.get("invitations") - workspace_invitations = WorkspaceMemberInvite.objects.filter(pk__in=invitations) + invitations = request.data.get("invitations", []) + workspace_invitations = WorkspaceMemberInvite.objects.filter( + pk__in=invitations, email=request.user.email + ).order_by("-created_at") + # If the user is already a member of workspace and was deactivated then activate the user + for invitation in workspace_invitations: + # Update the WorkspaceMember for this specific invitation + WorkspaceMember.objects.filter( + workspace_id=invitation.workspace_id, member=request.user + ).update(is_active=True, role=invitation.role) + + # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( [ WorkspaceMember( @@ -481,20 +504,24 @@ class WorkSpaceMemberViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug"), member__is_bot=False) + .filter( + workspace__slug=self.kwargs.get("slug"), + member__is_bot=False, + is_active=True, + ) .select_related("workspace", "workspace__owner") .select_related("member") ) def list(self, request, slug): workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug + member=request.user, + workspace__slug=slug, + is_active=True, ) - workspace_members = WorkspaceMember.objects.filter( - workspace__slug=slug, - member__is_bot=False, - ).select_related("workspace", "member") + # Get all active workspace members + workspace_members = self.get_queryset() if workspace_member.role > 10: serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) @@ -506,7 +533,12 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, pk): - workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug) + workspace_member = WorkspaceMember.objects.get( + pk=pk, + workspace__slug=slug, + member__is_bot=False, + is_active=True, + ) if request.user.id == workspace_member.member_id: return Response( {"error": "You cannot update your own role"}, @@ -515,7 +547,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): # Get the requested user role requested_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) # Check if role is being updated # One cannot update role higher than his own role @@ -540,68 +574,121 @@ class WorkSpaceMemberViewSet(BaseViewSet): def destroy(self, request, slug, pk): # Check the user role who is deleting the user - workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk) + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + pk=pk, + member__is_bot=False, + is_active=True, + ) # check requesting user role requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) + + if str(workspace_member.id) == str(requesting_workspace_member.id): + return Response( + { + "error": "You cannot remove yourself from the workspace. Please use leave workspace" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if requesting_workspace_member.role < workspace_member.role: return Response( {"error": "You cannot remove a user having role higher than you"}, status=status.HTTP_400_BAD_REQUEST, ) - # Check for the only member in the workspace if ( - workspace_member.role == 20 - and WorkspaceMember.objects.filter( - workspace__slug=slug, - role=20, - member__is_bot=False, - ).count() - == 1 + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=workspace_member.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() ): return Response( - {"error": "Cannot delete the only Admin for the workspace"}, + { + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." + }, status=status.HTTP_400_BAD_REQUEST, ) - # Delete the user also from all the projects - ProjectMember.objects.filter( - workspace__slug=slug, member=workspace_member.member - ).delete() - # Remove all favorites - ProjectFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - CycleFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - ModuleFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - PageFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - IssueViewFavorite.objects.filter( - workspace__slug=slug, user=workspace_member.member - ).delete() - # Also remove issue from issue assigned - IssueAssignee.objects.filter( - workspace__slug=slug, assignee=workspace_member.member - ).delete() + # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False) - # Remove if module member - ModuleMember.objects.filter( - workspace__slug=slug, member=workspace_member.member - ).delete() - # Delete owned Pages - Page.objects.filter( - workspace__slug=slug, owned_by=workspace_member.member - ).delete() + workspace_member.is_active = False + workspace_member.save() + return Response(status=status.HTTP_204_NO_CONTENT) - workspace_member.delete() + def leave(self, request, slug): + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, + member=request.user, + is_active=True, + ) + + # Check if the leaving user is the only admin of the workspace + if ( + workspace_member.role == 20 + and not WorkspaceMember.objects.filter( + workspace__slug=slug, + role=20, + is_active=True, + ).count() + > 1 + ): + return Response( + { + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ( + Project.objects.annotate( + total_members=Count("project_projectmember"), + member_with_role=Count( + "project_projectmember", + filter=Q( + project_projectmember__member_id=request.user.id, + project_projectmember__role=20, + ), + ), + ) + .filter(total_members=1, member_with_role=1, workspace__slug=slug) + .exists() + ): + return Response( + { + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # # Deactivate the users from the projects where the user is part of + _ = ProjectMember.objects.filter( + workspace__slug=slug, + member_id=workspace_member.member_id, + is_active=True, + ).update(is_active=False) + + # # Deactivate the user + workspace_member.is_active = False + workspace_member.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -629,7 +716,9 @@ class TeamMemberViewSet(BaseViewSet): def create(self, request, slug): members = list( WorkspaceMember.objects.filter( - workspace__slug=slug, member__id__in=request.data.get("members", []) + workspace__slug=slug, + member__id__in=request.data.get("members", []), + is_active=True, ) .annotate(member_str_id=Cast("member", output_field=CharField())) .distinct() @@ -658,23 +747,6 @@ class TeamMemberViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -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): user = User.objects.get(pk=request.user.id) @@ -711,7 +783,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView): def get(self, request, slug): workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug + member=request.user, + workspace__slug=slug, + is_active=True, ) serializer = WorkspaceMemberMeSerializer(workspace_member) return Response(serializer.data, status=status.HTTP_200_OK) @@ -720,7 +794,9 @@ class WorkspaceMemberUserEndpoint(BaseAPIView): class WorkspaceMemberUserViewsEndpoint(BaseAPIView): def post(self, request, slug): workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) workspace_member.view_props = request.data.get("view_props", {}) workspace_member.save() @@ -1046,7 +1122,9 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): user_data = User.objects.get(pk=user_id) requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user + workspace__slug=slug, + member=request.user, + is_active=True, ) projects = [] if requesting_workspace_member.role >= 10: @@ -1138,6 +1216,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ] def get(self, request, slug, user_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state @@ -1239,20 +1318,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True).data - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - grouped_results = group_results(issues, group_by) - return Response( - grouped_results, - status=status.HTTP_200_OK, - ) - - return Response( - issues, status=status.HTTP_200_OK - ) + issues = IssueLiteSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response(issue_dict, status=status.HTTP_200_OK) class WorkspaceLabelsEndpoint(BaseAPIView): @@ -1266,30 +1336,3 @@ class WorkspaceLabelsEndpoint(BaseAPIView): project__project_projectmember__member=request.user, ).values("parent", "name", "color", "id", "project_id", "workspace__slug") return Response(labels, status=status.HTTP_200_OK) - - -class LeaveWorkspaceEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] - - def delete(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user - ) - - # Only Admin case - if ( - workspace_member.role == 20 - and WorkspaceMember.objects.filter(workspace__slug=slug, role=20).count() - == 1 - ): - return Response( - { - "error": "You cannot leave the workspace since you are the only admin of the workspace you should delete the workspace" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - # Delete the member from workspace - workspace_member.delete() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index a80770c37..4aa86f6ca 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,9 +1,11 @@ # Python imports import csv import io +import requests +import json # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -16,6 +18,7 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters +from plane.license.utils.instance_value import get_email_configuration row_mapping = { "state__name": "State", @@ -30,7 +33,7 @@ row_mapping = { "priority": "Priority", "estimate": "Estimate", "issue_cycle__cycle_id": "Cycle", - "issue_module__module_id": "Module" + "issue_module__module_id": "Module", } ASSIGNEE_ID = "assignees__id" @@ -40,16 +43,41 @@ CYCLE_ID = "issue_cycle__cycle_id" MODULE_ID = "issue_module__module_id" -def send_export_email(email, slug, csv_buffer): +def send_export_email(email, slug, csv_buffer, rows): """Helper function to send export email.""" subject = "Your Export is ready" html_content = render_to_string("emails/exports/analytics.html", {}) text_content = strip_tags(html_content) csv_buffer.seek(0) - msg = EmailMultiAlternatives(subject, text_content, settings.EMAIL_FROM, [email]) + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) + return def get_assignee_details(slug, filters): @@ -417,8 +445,11 @@ def analytic_export_task(email, data, slug): ) csv_buffer = generate_csv_from_rows(rows) - send_export_email(email, slug, csv_buffer) + send_export_email(email, slug, csv_buffer, rows) + return except Exception as e: + print(e) if settings.DEBUG: print(e) capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py deleted file mode 100644 index 9f9d06437..000000000 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ /dev/null @@ -1,46 +0,0 @@ -# Django imports -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string -from django.utils.html import strip_tags -from django.conf import settings - -# Third party imports -from celery import shared_task - - -from sentry_sdk import capture_exception - -# Module imports -from plane.db.models import User - - -@shared_task -def email_verification(first_name, email, token, current_site): - - try: - realtivelink = "/request-email-verification/" + "?token=" + str(token) - abs_url = current_site + realtivelink - - from_email_string = settings.EMAIL_FROM - - subject = "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: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) - return diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py new file mode 100644 index 000000000..7d26dd4ab --- /dev/null +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -0,0 +1,78 @@ +import uuid +import os + +# third party imports +from celery import shared_task +from sentry_sdk import capture_exception +from posthog import Posthog + +# module imports +from plane.license.utils.instance_value import get_configuration_value + + +def posthogConfiguration(): + POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value( + [ + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", None), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, + ] + ) + if POSTHOG_API_KEY and POSTHOG_HOST: + return POSTHOG_API_KEY, POSTHOG_HOST + else: + return None, None + + +@shared_task +def auth_events(user, email, user_agent, ip, event_name, medium, first_time): + try: + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "medium": medium, + "first_time": first_time + } + ) + except Exception as e: + capture_exception(e) + + +@shared_task +def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): + try: + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "accepted_from": accepted_from + } + ) + except Exception as e: + capture_exception(e) \ No newline at end of file diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index 1329697e9..e895b859d 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -71,7 +71,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" expires_in = 7 * 24 * 60 * 60 - if settings.DOCKERIZED and settings.USE_MINIO: + if settings.USE_MINIO: s3 = boto3.client( "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL, @@ -105,14 +105,14 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ) s3.upload_fileobj( zip_file, - settings.AWS_S3_BUCKET_NAME, + settings.AWS_STORAGE_BUCKET_NAME, file_name, ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, ) presigned_url = s3.generate_presigned_url( "get_object", - Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name}, + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, ExpiresIn=expires_in, ) diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index 45c53eaca..30b638c84 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -21,7 +21,7 @@ def delete_old_s3_link(): expired_exporter_history = ExporterHistory.objects.filter( Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) ).values_list("key", "id") - if settings.DOCKERIZED and settings.USE_MINIO: + if settings.USE_MINIO: s3 = boto3.client( "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL, @@ -41,9 +41,9 @@ def delete_old_s3_link(): for file_name, exporter_id in expired_exporter_history: # Delete object from S3 if file_name: - if settings.DOCKERIZED and settings.USE_MINIO: + if settings.USE_MINIO: s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) else: - s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) ExporterHistory.objects.filter(id=exporter_id).update(url=None) diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py new file mode 100644 index 000000000..339d24583 --- /dev/null +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -0,0 +1,29 @@ +# Python imports +from datetime import timedelta + +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import FileAsset + + +@shared_task +def delete_file_asset(): + + # file assets to delete + file_assets_to_delete = FileAsset.objects.filter( + Q(is_deleted=True) & Q(updated_at__lte=timezone.now() - timedelta(days=7)) + ) + + # Delete the file from storage and the file object from the database + for file_asset in file_assets_to_delete: + # Delete the file from storage + file_asset.asset.delete(save=False) + # Delete the file object + file_asset.delete() + diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index de1390f01..563cc8a40 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,5 +1,10 @@ +# Python import +import os +import requests +import json + # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,29 +13,54 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception +# Module imports +from plane.license.utils.instance_value import get_email_configuration @shared_task def forgot_password(first_name, email, uidb64, token, current_site): - try: - realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}" - abs_url = current_site + realtivelink + relative_link = ( + f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}" + ) + abs_url = str(current_site) + relative_link - from_email_string = settings.EMAIL_FROM + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - subject = "Reset Your Password - Plane" + subject = "A new password to your Plane account has been requested" context = { "first_name": first_name, "forgot_password_url": abs_url, + "email": email, } 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]) + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 14bece21b..84d10ecd3 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -13,7 +13,7 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.api.serializers import ImporterSerializer +from plane.app.serializers import ImporterSerializer from plane.db.models import ( Importer, WorkspaceMember, @@ -73,6 +73,12 @@ def service_importer(service, importer_id): ] ) + # Check if any of the users are already member of workspace + _ = WorkspaceMember.objects.filter( + member__in=[user for user in workspace_users], + workspace_id=importer.workspace_id, + ).update(is_active=True) + # Add new users to Workspace and project automatically WorkspaceMember.objects.bulk_create( [ diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 4776bceab..3b2b40223 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -21,14 +21,11 @@ from plane.db.models import ( State, Cycle, Module, - IssueSubscriber, - Notification, - IssueAssignee, IssueReaction, CommentReaction, IssueComment, ) -from plane.api.serializers import IssueActivitySerializer +from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 4d77eb124..6a09b08ba 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -67,7 +67,7 @@ def archive_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - if issues_to_update: + if issues_to_update: Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) @@ -80,7 +80,7 @@ def archive_old_issues(): project_id=project_id, current_instance=json.dumps({"archived_at": None}), subscriber=False, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) for issue in issues_to_update ] @@ -142,17 +142,21 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + Issue.objects.bulk_update( + issues_to_update, ["state"], batch_size=100 + ) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"closed_to": str(issue.state_id)}), + requested_data=json.dumps( + {"closed_to": str(issue.state_id)} + ), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, current_instance=None, subscriber=False, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) for issue in issues_to_update ] @@ -161,4 +165,4 @@ def close_old_issues(): if settings.DEBUG: print(e) capture_exception(e) - return + return \ No newline at end of file diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 71f6db8da..55bbfa0d6 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,5 +1,10 @@ +# Python imports +import os +import requests +import json + # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,28 +13,49 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception +# Module imports +from plane.license.utils.instance_value import get_email_configuration + @shared_task def magic_link(email, key, token, current_site): try: - realtivelink = f"/magic-sign-in/?password={token}&key={key}" - abs_url = current_site + realtivelink + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - from_email_string = settings.EMAIL_FROM - - subject = "Login for Plane" - - context = {"magic_url": abs_url, "code": token} + # Send the mail + subject = f"Your unique Plane login code is {token}" + context = {"code": token, "email": email} 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]) + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() return except Exception as e: + print(e) capture_exception(e) # Print logs if in DEBUG mode if settings.DEBUG: diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 0c2199e44..4bc27d3ee 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -190,6 +190,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi issue_activities_created) if issue_activities_created is not None else None ) if type not in [ + "issue.activity.deleted", "cycle.activity.created", "cycle.activity.deleted", "module.activity.created", diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 8b8ef6e48..4ec06e623 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,5 +1,8 @@ +# Python import +import os + # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -10,26 +13,25 @@ from sentry_sdk import capture_exception # Module imports from plane.db.models import Project, User, ProjectMemberInvite - +from plane.license.utils.instance_value import get_email_configuration @shared_task -def project_invitation(email, project_id, token, current_site): +def project_invitation(email, project_id, token, current_site, invitor): try: + user = User.objects.get(email=invitor) 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}" + relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" abs_url = current_site + relativelink - from_email_string = settings.EMAIL_FROM - - subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane" + subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" context = { "email": email, - "first_name": project.created_by.first_name, + "first_name": user.first_name, "project_name": project.name, "invitation_url": abs_url, } @@ -43,7 +45,32 @@ def project_invitation(email, project_id, token, current_site): project_member_invite.message = text_content project_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py new file mode 100644 index 000000000..3681f002d --- /dev/null +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -0,0 +1,222 @@ +import requests +import uuid +import hashlib +import json +import hmac + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception + +from plane.db.models import ( + Webhook, + WebhookLog, + Project, + Issue, + Cycle, + Module, + ModuleIssue, + CycleIssue, + IssueComment, +) +from plane.api.serializers import ( + ProjectSerializer, + IssueSerializer, + CycleSerializer, + ModuleSerializer, + CycleIssueSerializer, + ModuleIssueSerializer, + IssueCommentSerializer, + IssueExpandSerializer, +) + +SERIALIZER_MAPPER = { + "project": ProjectSerializer, + "issue": IssueExpandSerializer, + "cycle": CycleSerializer, + "module": ModuleSerializer, + "cycle_issue": CycleIssueSerializer, + "module_issue": ModuleIssueSerializer, + "issue_comment": IssueCommentSerializer, +} + +MODEL_MAPPER = { + "project": Project, + "issue": Issue, + "cycle": Cycle, + "module": Module, + "cycle_issue": CycleIssue, + "module_issue": ModuleIssue, + "issue_comment": IssueComment, +} + + +def get_model_data(event, event_id, many=False): + model = MODEL_MAPPER.get(event) + if many: + queryset = model.objects.filter(pk__in=event_id) + else: + queryset = model.objects.get(pk=event_id) + serializer = SERIALIZER_MAPPER.get(event) + return serializer(queryset, many=many).data + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_task(self, webhook, slug, event, event_data, action): + try: + webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + action = { + "POST": "create", + "PATCH": "update", + "PUT": "update", + "DELETE": "delete", + }.get(action, action) + + payload = { + "event": event, + "action": action, + "webhook_id": str(webhook.id), + "workspace_id": str(webhook.workspace_id), + "data": event_data, + } + + # Use HMAC for generating signature + if webhook.secret_key: + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + json.dumps(payload).encode("utf-8"), + hashlib.sha256, + ) + signature = hmac_signature.hexdigest() + headers["X-Plane-Signature"] = signature + + # Send the webhook event + response = requests.post( + webhook.url, + headers=headers, + json=payload, + timeout=30, + ) + + # Log the webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=str(response.status_code), + response_headers=str(response.headers), + response_body=str(response.text), + retry_count=str(self.request.retries), + ) + + except requests.RequestException as e: + # Log the failed webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=500, + response_headers="", + response_body=str(e), + retry_count=str(self.request.retries), + ) + + raise requests.RequestException() + + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return + + +@shared_task() +def send_webhook(event, payload, kw, action, slug, bulk): + try: + webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) + + if event == "project": + webhooks = webhooks.filter(project=True) + + if event == "issue": + webhooks = webhooks.filter(issue=True) + + if event == "module" or event == "module_issue": + webhooks = webhooks.filter(module=True) + + if event == "cycle" or event == "cycle_issue": + webhooks = webhooks.filter(cycle=True) + + if event == "issue_comment": + webhooks = webhooks.filter(issue_comment=True) + + if webhooks: + if action in ["POST", "PATCH"]: + if bulk and event in ["cycle_issue", "module_issue"]: + event_data = IssueExpandSerializer( + Issue.objects.filter( + pk__in=[ + str(event.get("issue")) for event in payload + ] + ).prefetch_related("issue_cycle", "issue_module"), many=True + ).data + event = "issue" + action = "PATCH" + else: + event_data = [ + get_model_data( + event=event, + event_id=payload.get("id") if isinstance(payload, dict) else None, + many=False, + ) + ] + + if action == "DELETE": + event_data = [{"id": kw.get("pk")}] + + for webhook in webhooks: + for data in event_data: + webhook_task.delay( + webhook=webhook.id, + slug=slug, + event=event, + event_data=data, + action=action, + ) + + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 94be6f879..1bdc48ca3 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,5 +1,10 @@ +# Python imports +import os +import requests +import json + # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -11,31 +16,44 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import Workspace, WorkspaceMemberInvite +from plane.db.models import Workspace, WorkspaceMemberInvite, User +from plane.license.utils.instance_value import get_email_configuration @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: + user = User.objects.get(email=invitor) + workspace = Workspace.objects.get(pk=workspace_id) workspace_member_invite = WorkspaceMemberInvite.objects.get( token=token, email=email ) - realtivelink = ( - f"/workspace-member-invitation/?invitation_id={workspace_member_invite.id}&email={email}" - ) - abs_url = current_site + realtivelink + # Relative link + relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" - from_email_string = settings.EMAIL_FROM + # The complete url including the domain + abs_url = str(current_site) + relative_link - subject = f"{invitor or email} invited you to join {workspace.name} on Plane" + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + # Subject of the email + subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" context = { "email": email, - "first_name": invitor, + "first_name": user.first_name or user.display_name or user.email, "workspace_name": workspace.name, - "invitation_url": abs_url, + "abs_url": abs_url, } html_content = render_to_string( @@ -47,7 +65,21 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): workspace_member_invite.message = text_content workspace_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() @@ -64,6 +96,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: + print("Workspace or WorkspaceMember Invite Does not exists") return except Exception as e: # Print logs if in DEBUG mode diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index dfb094339..442e72836 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -24,9 +24,13 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", "schedule": crontab(hour=0, minute=0), }, + "check-every-day-to-delete-file-asset": { + "task": "plane.bgtasks.file_asset_task.delete_file_asset", + "schedule": crontab(hour=0, minute=0), + }, } # Load task modules from all registered Django app configs. app.autodiscover_tasks() -app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' +app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler" diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py new file mode 100644 index 000000000..054523bf9 --- /dev/null +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -0,0 +1,71 @@ +# Python imports +import boto3 +import json +from botocore.exceptions import ClientError + +# Django imports +from django.core.management import BaseCommand +from django.conf import settings + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def set_bucket_public_policy(self, s3_client, bucket_name): + public_policy = { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket_name}/*"] + }] + } + + try: + s3_client.put_bucket_policy( + Bucket=bucket_name, + Policy=json.dumps(public_policy) + ) + self.stdout.write(self.style.SUCCESS(f"Public read access policy set for bucket '{bucket_name}'.")) + except ClientError as e: + self.stdout.write(self.style.ERROR(f"Error setting public read access policy: {e}")) + + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + session = boto3.session.Session( + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + # Create an S3 client using the session + s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + + self.stdout.write(self.style.NOTICE("Checking bucket...")) + + # Check if the bucket exists + s3_client.head_bucket(Bucket=bucket_name) + + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as e: + error_code = int(e.response['Error']['Code']) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + if error_code == 404: + # Bucket does not exist, create it + self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + try: + s3_client.create_bucket(Bucket=bucket_name) + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as create_error: + self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) + elif error_code == 403: + # Access to the bucket is forbidden + self.stdout.write(self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")) + else: + # Another ClientError occurred + self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) \ No newline at end of file diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py new file mode 100644 index 000000000..a5b4c9cc8 --- /dev/null +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -0,0 +1,54 @@ +# Python imports +import getpass + +# Django imports +from django.core.management import BaseCommand + +# Module imports +from plane.db.models import User + + +class Command(BaseCommand): + help = "Reset password of the user with the given email" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("email", type=str, help="user email") + + def handle(self, *args, **options): + # get the user email from console + email = options.get("email", False) + + # raise error if email is not present + if not email: + self.stderr.write("Error: Email is required") + return + + # filter the user + user = User.objects.filter(email=email).first() + + # Raise error if the user is not present + if not user: + self.stderr.write(f"Error: User with {email} does not exists") + return + + # get password for the user + password = getpass.getpass("Password: ") + confirm_password = getpass.getpass("Password (again): ") + + # If the passwords doesn't match raise error + if password != confirm_password: + self.stderr.write("Error: Your passwords didn't match.") + return + + # Blank passwords should not be allowed + if password.strip() == "": + self.stderr.write("Error: Blank passwords aren't allowed.") + return + + # Set user password + user.set_password(password) + user.is_password_autoset = False + user.save() + + self.stdout.write(self.style.SUCCESS(f"User password updated succesfully")) diff --git a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py index 500bc3b28..03eaeacd7 100644 --- a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py +++ b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import plane.db.models.api_token +import plane.db.models.api import uuid @@ -40,8 +40,8 @@ class Migration(migrations.Migration): ('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)), - ('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)), - ('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)), + ('token', models.CharField(default=plane.db.models.api.generate_token, max_length=255, unique=True)), + ('label', models.CharField(default=plane.db.models.api.generate_label_token, max_length=255)), ('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_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='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py deleted file mode 100644 index ae5753e07..000000000 --- a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.2.5 on 2023-10-18 12:04 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import plane.db.models.issue -import uuid - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), - ] - - operations = [ - migrations.CreateModel( - name="issue_mentions", - 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)), - ('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuemention', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuemention_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_issuemention', to='db.workspace')), - ], - options={ - 'verbose_name': 'IssueMention', - 'verbose_name_plural': 'IssueMentions', - 'db_table': 'issue_mentions', - 'ordering': ('-created_at',), - }, - ), - migrations.AlterField( - model_name='issueproperty', - name='properties', - field=models.JSONField(default=plane.db.models.issue.get_default_properties), - ), - ] \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..f02660e1d --- /dev/null +++ b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py @@ -0,0 +1,984 @@ +# Generated by Django 4.2.5 on 2023-11-15 09:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.issue +import uuid +import random + +def random_sort_ordering(apps, schema_editor): + Label = apps.get_model("db", "Label") + + bulk_labels = [] + for label in Label.objects.all(): + label.sort_order = random.randint(0,65535) + bulk_labels.append(label) + + Label.objects.bulk_update(bulk_labels, ["sort_order"], batch_size=1000) + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AlterField( + model_name='analyticview', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='analyticview', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='apitoken', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='apitoken', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='cycle', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='cycle', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='cycle', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='cycle', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='cyclefavorite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='cyclefavorite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='cyclefavorite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='cyclefavorite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='cycleissue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='cycleissue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='cycleissue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='cycleissue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='estimate', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='estimate', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='estimate', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='estimate', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='estimatepoint', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='estimatepoint', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='estimatepoint', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='estimatepoint', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='fileasset', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='fileasset', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='githubcommentsync', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='githubcommentsync', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='githubcommentsync', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='githubcommentsync', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='githubissuesync', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='githubissuesync', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='githubissuesync', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='githubissuesync', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='githubrepository', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='githubrepository', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='githubrepository', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='githubrepository', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='githubrepositorysync', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='githubrepositorysync', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='githubrepositorysync', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='githubrepositorysync', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='importer', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='importer', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='importer', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='importer', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='inbox', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='inbox', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='inbox', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='inbox', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='inboxissue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='inboxissue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='inboxissue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='inboxissue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='integration', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='integration', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueactivity', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueactivity', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueactivity', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueactivity', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueassignee', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueassignee', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueassignee', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueassignee', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueattachment', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueattachment', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueattachment', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueattachment', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueblocker', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueblocker', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueblocker', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueblocker', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issuecomment', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issuecomment', + name='issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_comments', to='db.issue'), + ), + migrations.AlterField( + model_name='issuecomment', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issuecomment', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issuecomment', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issuelabel', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issuelabel', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issuelabel', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issuelabel', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issuelink', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issuelink', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issuelink', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issuelink', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueproperty', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueproperty', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueproperty', + name='properties', + field=models.JSONField(default=plane.db.models.issue.get_default_properties), + ), + migrations.AlterField( + model_name='issueproperty', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueproperty', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issuesequence', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issuesequence', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issuesequence', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issuesequence', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueview', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueview', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueview', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueview', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='issueviewfavorite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='issueviewfavorite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='issueviewfavorite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='issueviewfavorite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='label', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='label', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='label', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='label', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='module', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='module', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='module', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='module', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='modulefavorite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='modulefavorite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='modulefavorite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='modulefavorite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='moduleissue', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='moduleissue', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='moduleissue', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='moduleissue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='modulelink', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='modulelink', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='modulelink', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='modulelink', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='modulemember', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='modulemember', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='modulemember', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='modulemember', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='page', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='page', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='page', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='page', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='pageblock', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='pageblock', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='pageblock', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='pageblock', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='pagefavorite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='pagefavorite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='pagefavorite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='pagefavorite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='pagelabel', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='pagelabel', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='pagelabel', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='pagelabel', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='project', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='project', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='projectfavorite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='projectfavorite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='projectfavorite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='projectfavorite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='projectidentifier', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='projectidentifier', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='projectmember', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='projectmember', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='projectmember', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='projectmember', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='projectmemberinvite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='projectmemberinvite', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='projectmemberinvite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='projectmemberinvite', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='slackprojectsync', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='slackprojectsync', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='slackprojectsync', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='slackprojectsync', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='socialloginconnection', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='socialloginconnection', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='state', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='state', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='state', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='state', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='team', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='team', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='teammember', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='teammember', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='workspace', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='workspace', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='workspaceintegration', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='workspaceintegration', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='workspacemember', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='workspacemember', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='workspacememberinvite', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='workspacememberinvite', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AlterField( + model_name='workspacetheme', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + ), + migrations.AlterField( + model_name='workspacetheme', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.CreateModel( + name='IssueMention', + 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='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to='db.issue')), + ('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Mention', + 'verbose_name_plural': 'Issue Mentions', + 'db_table': 'issue_mentions', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'mention')}, + }, + ), + migrations.RunPython(random_sort_ordering), + ] diff --git a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py new file mode 100644 index 000000000..d44f760d0 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py @@ -0,0 +1,131 @@ +# Generated by Django 4.2.5 on 2023-11-15 11:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.api +import plane.db.models.webhook +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_label_sort_order_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + 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)), + ('url', models.URLField(validators=[plane.db.models.webhook.validate_schema, plane.db.models.webhook.validate_domain])), + ('is_active', models.BooleanField(default=True)), + ('secret_key', models.CharField(default=plane.db.models.webhook.generate_token, max_length=255)), + ('project', models.BooleanField(default=False)), + ('issue', models.BooleanField(default=False)), + ('module', models.BooleanField(default=False)), + ('cycle', models.BooleanField(default=False)), + ('issue_comment', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_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_webhooks', to='db.workspace')), + ], + options={ + 'verbose_name': 'Webhook', + 'verbose_name_plural': 'Webhooks', + 'db_table': 'webhooks', + 'ordering': ('-created_at',), + 'unique_together': {('workspace', 'url')}, + }, + ), + migrations.AddField( + model_name='apitoken', + name='description', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='apitoken', + name='expired_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='apitoken', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='apitoken', + name='last_used', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='projectmember', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='workspacemember', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='apitoken', + name='token', + field=models.CharField(db_index=True, default=plane.db.models.api.generate_token, max_length=255, unique=True), + ), + migrations.CreateModel( + name='WebhookLog', + 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)), + ('event_type', models.CharField(blank=True, max_length=255, null=True)), + ('request_method', models.CharField(blank=True, max_length=10, null=True)), + ('request_headers', models.TextField(blank=True, null=True)), + ('request_body', models.TextField(blank=True, null=True)), + ('response_status', models.TextField(blank=True, null=True)), + ('response_headers', models.TextField(blank=True, null=True)), + ('response_body', models.TextField(blank=True, null=True)), + ('retry_count', models.PositiveSmallIntegerField(default=0)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='db.webhook')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_logs', to='db.workspace')), + ], + options={ + 'verbose_name': 'Webhook Log', + 'verbose_name_plural': 'Webhook Logs', + 'db_table': 'webhook_logs', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='APIActivityLog', + 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)), + ('token_identifier', models.CharField(max_length=255)), + ('path', models.CharField(max_length=255)), + ('method', models.CharField(max_length=10)), + ('query_params', models.TextField(blank=True, null=True)), + ('headers', models.TextField(blank=True, null=True)), + ('body', models.TextField(blank=True, null=True)), + ('response_code', models.PositiveIntegerField()), + ('response_body', models.TextField(blank=True, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, max_length=512, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'API Activity Log', + 'verbose_name_plural': 'API Activity Logs', + 'db_table': 'api_activity_logs', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py new file mode 100644 index 000000000..8d896b01d --- /dev/null +++ b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.5 on 2023-11-13 12:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0047_webhook_apitoken_description_apitoken_expired_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PageLog', + 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)), + ('transaction', models.UUIDField(default=uuid.uuid4)), + ('entity_identifier', models.UUIDField(null=True)), + ('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('page_mention', 'Page Mention'), ('user_mention', 'User Mention')], max_length=30, verbose_name='Transaction Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Log', + 'verbose_name_plural': 'Page Logs', + 'db_table': 'page_logs', + 'ordering': ('-created_at',), + 'unique_together': {('page', 'transaction')} + }, + ), + migrations.AddField( + model_name='page', + name='archived_at', + field=models.DateField(null=True), + ), + migrations.AddField( + model_name='page', + name='is_locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='page', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_page', to='db.page'), + ), + ] \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py new file mode 100644 index 000000000..75d5e5982 --- /dev/null +++ b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.5 on 2023-11-15 09:16 + +# Python imports +import uuid + +from django.db import migrations + + +def update_pages(apps, schema_editor): + try: + Page = apps.get_model("db", "Page") + PageBlock = apps.get_model("db", "PageBlock") + PageLog = apps.get_model("db", "PageLog") + + updated_pages = [] + page_logs = [] + + # looping through all the pages + for page in Page.objects.all(): + page_blocks = PageBlock.objects.filter( + page_id=page.id, project_id=page.project_id, workspace_id=page.workspace_id + ).order_by("sort_order") + + if page_blocks: + # looping through all the page blocks in a page + for page_block in page_blocks: + if page_block.issue is not None: + project_identifier = page.project.identifier + sequence_id = page_block.issue.sequence_id + transaction = uuid.uuid4().hex + embed_component = f'' + page.description_html += embed_component + + # create the page transaction for the issue + page_logs.append( + PageLog( + page_id=page_block.page_id, + transaction=transaction, + entity_identifier=page_block.issue_id, + entity_name="issue", + project_id=page.project_id, + workspace_id=page.workspace_id, + created_by_id=page_block.created_by_id, + updated_by_id=page_block.updated_by_id, + ) + ) + else: + # adding the page block name and description to the page description + page.description_html += f"

{page_block.name}

" + page.description_html += page_block.description_html + + updated_pages.append(page) + + Page.objects.bulk_update( + updated_pages, + ["description_html"], + batch_size=100, + ) + PageLog.objects.bulk_create(page_logs, batch_size=100) + + except Exception as e: + print(e) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0048_auto_20231116_0713"), + ] + + operations = [ + migrations.RunPython(update_pages), + ] \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py new file mode 100644 index 000000000..a8807d104 --- /dev/null +++ b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.5 on 2023-11-17 08:48 + +from django.db import migrations, models +import plane.db.models.workspace + +def user_password_autoset(apps, schema_editor): + User = apps.get_model("db", "User") + User.objects.update(is_password_autoset=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0049_auto_20231116_0713'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='use_case', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='workspace', + name='organization_size', + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name='fileasset', + name='is_deleted', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='workspace', + name='slug', + field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]), + ), + migrations.RunPython(user_password_autoset), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index d8286f8f8..c76df6e5b 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -54,7 +54,7 @@ from .view import GlobalView, IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite -from .api_token import APIToken +from .api import APIToken, APIActivityLog from .integration import ( WorkspaceIntegration, @@ -68,7 +68,7 @@ from .integration import ( from .importer import Importer -from .page import Page, PageBlock, PageFavorite, PageLabel +from .page import Page, PageLog, PageFavorite, PageLabel from .estimate import Estimate, EstimatePoint @@ -79,3 +79,5 @@ from .analytic import AnalyticView from .notification import Notification from .exporter import ExporterHistory + +from .webhook import Webhook, WebhookLog diff --git a/apiserver/plane/db/models/api.py b/apiserver/plane/db/models/api.py new file mode 100644 index 000000000..0fa1d4aba --- /dev/null +++ b/apiserver/plane/db/models/api.py @@ -0,0 +1,80 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models +from django.conf import settings + +from .base import BaseModel + + +def generate_label_token(): + return uuid4().hex + + +def generate_token(): + return "plane_api_" + uuid4().hex + + +class APIToken(BaseModel): + # Meta information + label = models.CharField(max_length=255, default=generate_label_token) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + last_used = models.DateTimeField(null=True) + + # Token + token = models.CharField( + max_length=255, unique=True, default=generate_token, db_index=True + ) + + # User Information + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="bot_tokens", + ) + user_type = models.PositiveSmallIntegerField( + choices=((0, "Human"), (1, "Bot")), default=0 + ) + workspace = models.ForeignKey( + "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True + ) + expired_at = models.DateTimeField(blank=True, null=True) + + class Meta: + verbose_name = "API Token" + verbose_name_plural = "API Tokems" + db_table = "api_tokens" + ordering = ("-created_at",) + + def __str__(self): + return str(self.user.id) + + +class APIActivityLog(BaseModel): + token_identifier = models.CharField(max_length=255) + + # Request Info + path = models.CharField(max_length=255) + method = models.CharField(max_length=10) + query_params = models.TextField(null=True, blank=True) + headers = models.TextField(null=True, blank=True) + body = models.TextField(null=True, blank=True) + + # Response info + response_code = models.PositiveIntegerField() + response_body = models.TextField(null=True, blank=True) + + # Meta information + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=512, null=True, blank=True) + + class Meta: + verbose_name = "API Activity Log" + verbose_name_plural = "API Activity Logs" + db_table = "api_activity_logs" + ordering = ("-created_at",) + + def __str__(self): + return str(self.token_identifier) diff --git a/apiserver/plane/db/models/api_token.py b/apiserver/plane/db/models/api_token.py deleted file mode 100644 index b4009e6eb..000000000 --- a/apiserver/plane/db/models/api_token.py +++ /dev/null @@ -1,41 +0,0 @@ -# Python imports -from uuid import uuid4 - -# Django imports -from django.db import models -from django.conf import settings - -from .base import BaseModel - - -def generate_label_token(): - return uuid4().hex - - -def generate_token(): - return uuid4().hex + uuid4().hex - - -class APIToken(BaseModel): - token = models.CharField(max_length=255, unique=True, default=generate_token) - label = models.CharField(max_length=255, default=generate_label_token) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="bot_tokens", - ) - user_type = models.PositiveSmallIntegerField( - choices=((0, "Human"), (1, "Bot")), default=0 - ) - workspace = models.ForeignKey( - "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True - ) - - class Meta: - verbose_name = "API Token" - verbose_name_plural = "API Tokems" - db_table = "api_tokens" - ordering = ("-created_at",) - - def __str__(self): - return str(self.user.name) diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 01ef1d9d8..ab3c38d9c 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -36,6 +36,7 @@ class FileAsset(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" ) + is_deleted = models.BooleanField(default=False) class Meta: verbose_name = "File Asset" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 0c227a158..9b293a75d 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -7,7 +7,6 @@ from django.db import models from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils import timezone from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError @@ -132,25 +131,8 @@ class Issue(ProjectBaseModel): self.state = default_state except ImportError: pass - else: - try: - from plane.db.models import State, PageBlock - # Check if the current issue state and completed state id are same - if self.state.group == "completed": - self.completed_at = timezone.now() - # check if there are any page blocks - PageBlock.objects.filter(issue_id=self.id).filter().update( - completed_at=timezone.now() - ) - else: - PageBlock.objects.filter(issue_id=self.id).filter().update( - completed_at=None - ) - self.completed_at = None - except ImportError: - pass if self._state.adding: # Get the maximum display_id value from the database last_id = IssueSequence.objects.filter(project=self.project).aggregate( @@ -158,8 +140,10 @@ class Issue(ProjectBaseModel): )["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: + if last_id: self.sequence_id = last_id + 1 + else: + self.sequence_id = 1 largest_sort_order = Issue.objects.filter( project=self.project, state=self.state @@ -431,6 +415,7 @@ class Label(ProjectBaseModel): name = models.CharField(max_length=255) description = models.TextField(blank=True) color = models.CharField(max_length=255, blank=True) + sort_order = models.FloatField(default=65535) class Meta: unique_together = ["name", "project"] @@ -439,6 +424,18 @@ class Label(ProjectBaseModel): db_table = "labels" ordering = ("-created_at",) + def save(self, *args, **kwargs): + if self._state.adding: + # Get the maximum sequence value from the database + last_id = Label.objects.filter(project=self.project).aggregate( + largest=models.Max("sort_order") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sort_order = last_id + 10000 + + super(Label, self).save(*args, **kwargs) + def __str__(self): return str(self.name) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index e286d297a..ae540cc6c 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -51,9 +51,9 @@ class Module(ProjectBaseModel): def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Module.objects.filter( - project=self.project - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + smallest_sort_order = Module.objects.filter(project=self.project).aggregate( + smallest=models.Min("sort_order") + )["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 557fcb323..de65cb98f 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -1,3 +1,5 @@ +import uuid + # Django imports from django.db import models from django.conf import settings @@ -22,6 +24,15 @@ class Page(ProjectBaseModel): labels = models.ManyToManyField( "db.Label", blank=True, related_name="pages", through="db.PageLabel" ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="child_page", + ) + archived_at = models.DateField(null=True) + is_locked = models.BooleanField(default=False) class Meta: verbose_name = "Page" @@ -34,6 +45,43 @@ class Page(ProjectBaseModel): return f"{self.owned_by.email} <{self.name}>" +class PageLog(ProjectBaseModel): + TYPE_CHOICES = ( + ("to_do", "To Do"), + ("issue", "issue"), + ("image", "Image"), + ("video", "Video"), + ("file", "File"), + ("link", "Link"), + ("cycle","Cycle"), + ("module", "Module"), + ("back_link", "Back Link"), + ("forward_link", "Forward Link"), + ("page_mention", "Page Mention"), + ("user_mention", "User Mention"), + ) + transaction = models.UUIDField(default=uuid.uuid4) + page = models.ForeignKey( + Page, related_name="page_log", on_delete=models.CASCADE + ) + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + verbose_name="Transaction Type", + ) + + class Meta: + unique_together = ["page", "transaction"] + verbose_name = "Page Log" + verbose_name_plural = "Page Logs" + db_table = "page_logs" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.page.name} {self.type}" + + class PageBlock(ProjectBaseModel): page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") name = models.CharField(max_length=255) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index f4ace65e5..fe72c260b 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -166,6 +166,7 @@ class ProjectMember(ProjectBaseModel): default_props = models.JSONField(default=get_default_props) preferences = models.JSONField(default=get_default_preferences) sort_order = models.FloatField(default=65535) + is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): if self._state.adding: diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index e90e19c5e..fe75a6a26 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -86,6 +86,7 @@ class User(AbstractBaseUser, PermissionsMixin): display_name = models.CharField(max_length=255, default="") is_tour_completed = models.BooleanField(default=False) onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) USERNAME_FIELD = "email" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py new file mode 100644 index 000000000..ea2b508e5 --- /dev/null +++ b/apiserver/plane/db/models/webhook.py @@ -0,0 +1,89 @@ +# Python imports +from uuid import uuid4 +from urllib.parse import urlparse + +# Django imports +from django.db import models +from django.core.exceptions import ValidationError + +# Module imports +from plane.db.models import BaseModel + + +def generate_token(): + return "plane_wh_" + uuid4().hex + + +def validate_schema(value): + parsed_url = urlparse(value) + if parsed_url.scheme not in ["http", "https"]: + raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") + + +def validate_domain(value): + parsed_url = urlparse(value) + domain = parsed_url.netloc + if domain in ["localhost", "127.0.0.1"]: + raise ValidationError("Local URLs are not allowed.") + + +class Webhook(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_webhooks", + ) + url = models.URLField( + validators=[ + validate_schema, + validate_domain, + ] + ) + is_active = models.BooleanField(default=True) + secret_key = models.CharField(max_length=255, default=generate_token) + project = models.BooleanField(default=False) + issue = models.BooleanField(default=False) + module = models.BooleanField(default=False) + cycle = models.BooleanField(default=False) + issue_comment = models.BooleanField(default=False) + + def __str__(self): + return f"{self.workspace.slug} {self.url}" + + class Meta: + unique_together = ["workspace", "url"] + verbose_name = "Webhook" + verbose_name_plural = "Webhooks" + db_table = "webhooks" + ordering = ("-created_at",) + + +class WebhookLog(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" + ) + # Associated webhook + webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") + + # Basic request details + event_type = models.CharField(max_length=255, blank=True, null=True) + request_method = models.CharField(max_length=10, blank=True, null=True) + request_headers = models.TextField(blank=True, null=True) + request_body = models.TextField(blank=True, null=True) + + # Response details + response_status = models.TextField(blank=True, null=True) + response_headers = models.TextField(blank=True, null=True) + response_body = models.TextField(blank=True, null=True) + + # Retry Count + retry_count = models.PositiveSmallIntegerField(default=0) + + class Meta: + verbose_name = "Webhook Log" + verbose_name_plural = "Webhook Logs" + db_table = "webhook_logs" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.event_type} {str(self.webhook.url)}" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index d1012f549..505bfbcfa 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,6 +1,7 @@ # Django imports from django.db import models from django.conf import settings +from django.core.exceptions import ValidationError # Module imports from . import BaseModel @@ -50,7 +51,7 @@ def get_default_props(): "state": True, "sub_issue_count": True, "updated_on": True, - } + }, } @@ -63,6 +64,23 @@ def get_issue_props(): } +def slug_validator(value): + if value in [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + ]: + raise ValidationError("Slug is not valid") + + class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) @@ -71,8 +89,8 @@ class Workspace(BaseModel): on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=48, db_index=True, unique=True) - organization_size = models.CharField(max_length=20) + slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,]) + organization_size = models.CharField(max_length=20, blank=True, null=True) def __str__(self): """Return name of the Workspace""" @@ -99,6 +117,7 @@ class WorkspaceMember(BaseModel): view_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props) issue_props = models.JSONField(default=get_issue_props) + is_active = models.BooleanField(default=True) class Meta: unique_together = ["workspace", "member"] diff --git a/apiserver/plane/license/__init__.py b/apiserver/plane/license/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/__init__.py b/apiserver/plane/license/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/permissions/__init__.py b/apiserver/plane/license/api/permissions/__init__.py new file mode 100644 index 000000000..d5bedc4c0 --- /dev/null +++ b/apiserver/plane/license/api/permissions/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceAdminPermission diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py new file mode 100644 index 000000000..dff16605a --- /dev/null +++ b/apiserver/plane/license/api/permissions/instance.py @@ -0,0 +1,19 @@ +# Third party imports +from rest_framework.permissions import BasePermission + +# Module imports +from plane.license.models import Instance, InstanceAdmin + + +class InstanceAdminPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter( + role__gte=15, + instance=instance, + user=request.user, + ).exists() diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py new file mode 100644 index 000000000..b658ff148 --- /dev/null +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py new file mode 100644 index 000000000..173d718d9 --- /dev/null +++ b/apiserver/plane/license/api/serializers/instance.py @@ -0,0 +1,49 @@ +# Module imports +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.app.serializers import BaseSerializer +from plane.app.serializers import UserAdminLiteSerializer +from plane.license.utils.encryption import decrypt_data + +class InstanceSerializer(BaseSerializer): + primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + + class Meta: + model = Instance + fields = "__all__" + read_only_fields = [ + "id", + "instance_id", + "license_key", + "api_key", + "version", + "email", + "last_checked_at", + "is_setup_done", + ] + + +class InstanceAdminSerializer(BaseSerializer): + user_detail = UserAdminLiteSerializer(source="user", read_only=True) + + class Meta: + model = InstanceAdmin + fields = "__all__" + read_only_fields = [ + "id", + "instance", + "user", + ] + +class InstanceConfigurationSerializer(BaseSerializer): + + class Meta: + model = InstanceConfiguration + fields = "__all__" + + def to_representation(self, instance): + data = super().to_representation(instance) + # Decrypt secrets value + if instance.is_encrypted and instance.value is not None: + data["value"] = decrypt_data(instance.value) + + return data diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py new file mode 100644 index 000000000..3a66c94c5 --- /dev/null +++ b/apiserver/plane/license/api/views/__init__.py @@ -0,0 +1,7 @@ +from .instance import ( + InstanceEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, + InstanceAdminSignInEndpoint, + SignUpScreenVisitedEndpoint, +) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py new file mode 100644 index 000000000..0e40b897f --- /dev/null +++ b/apiserver/plane/license/api/views/instance.py @@ -0,0 +1,296 @@ +# Python imports +import json +import os +import requests +import uuid +import random +import string + +# Django imports +from django.utils import timezone +from django.contrib.auth.hashers import make_password +from django.core.validators import validate_email +from django.core.exceptions import ValidationError +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework_simplejwt.tokens import RefreshToken + +# Module imports +from plane.app.views import BaseAPIView +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.license.api.serializers import ( + InstanceSerializer, + InstanceAdminSerializer, + InstanceConfigurationSerializer, +) +from plane.license.api.permissions import ( + InstanceAdminPermission, +) +from plane.db.models import User, WorkspaceMember, ProjectMember +from plane.license.utils.encryption import encrypt_data + + +class InstanceEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method == "PATCH": + return [ + InstanceAdminPermission(), + ] + return [ + AllowAny(), + ] + + def get(self, request): + instance = Instance.objects.first() + # get the instance + if instance is None: + return Response( + {"is_activated": False, "is_setup_done": False}, + status=status.HTTP_200_OK, + ) + # Return instance + serializer = InstanceSerializer(instance) + data = serializer.data + data["is_activated"] = True + return Response(data, status=status.HTTP_200_OK) + + def patch(self, request): + # Get the instance + instance = Instance.objects.first() + serializer = InstanceSerializer(instance, data=request.data, 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) + + +class InstanceAdminEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + # Create an instance admin + def post(self, request): + email = request.data.get("email", False) + role = request.data.get("role", 20) + + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the user + user = User.objects.get(email=email) + + instance_admin = InstanceAdmin.objects.create( + instance=instance, + user=user, + role=role, + ) + serializer = InstanceAdminSerializer(instance_admin) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + instance_admins = InstanceAdmin.objects.filter(instance=instance) + serializer = InstanceAdminSerializer(instance_admins, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, pk): + instance = Instance.objects.first() + instance_admin = InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceConfigurationEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + def get(self, request): + instance_configurations = InstanceConfiguration.objects.all() + serializer = InstanceConfigurationSerializer(instance_configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + configurations = InstanceConfiguration.objects.filter( + key__in=request.data.keys() + ) + + bulk_configurations = [] + for configuration in configurations: + value = request.data.get(configuration.key, configuration.value) + if configuration.is_encrypted: + configuration.value = encrypt_data(value) + else: + configuration.value = value + bulk_configurations.append(configuration) + + InstanceConfiguration.objects.bulk_update( + bulk_configurations, ["value"], batch_size=100 + ) + + serializer = InstanceConfigurationSerializer(configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + return ( + str(refresh.access_token), + str(refresh), + ) + + +class InstanceAdminSignInEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + # Check instance first + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # check if the instance is already activated + if InstanceAdmin.objects.first(): + return Response( + {"error": "Admin for this instance is already registered"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the email and password from all the user + email = request.data.get("email", False) + password = request.data.get("password", False) + + # return error if the email and password is not present + if not email or not password: + return Response( + {"error": "Email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate the email + 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, + ) + + # Check if already a user exists or not + user = User.objects.filter(email=email).first() + + # Existing user + if user: + # Check user password + 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, + ) + else: + user = User.objects.create( + email=email, + username=uuid.uuid4().hex, + password=make_password(password), + is_password_autoset=False, + ) + + # if the current user is not using captain then add the current all users to workspace and projects + if user.email != "captain@plane.so": + # Add the current user also as a workspace member and project memeber to all the workspaces and projects + captain = User.objects.filter(email="captain@plane.so") + # Workspace members + workspace_members = WorkspaceMember.objects.filter(member=captain) + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=member.workspace_id, + member=user, + role=member.role, + ) + for member in workspace_members + ], + batch_size=100, + ) + # project members + project_members = ProjectMember.objects.filter(member=captain) + ProjectMember.objects.bulk_create( + [ + ProjectMember( + workspace=member.workspace_id, + member=user, + role=member.role, + ) + for member in project_members + ], + batch_size=100, + ) + + # settings last active for the user + user.is_active = True + 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() + + # Register the user as an instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + ) + # Make the setup flag True + instance.is_setup_done = True + instance.save() + + # get tokens for user + access_token, refresh_token = get_tokens_for_user(user) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + return Response(data, status=status.HTTP_200_OK) + + +class SignUpScreenVisitedEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def post(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + instance.is_signup_screen_visited = True + instance.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/license/apps.py b/apiserver/plane/license/apps.py new file mode 100644 index 000000000..400e98155 --- /dev/null +++ b/apiserver/plane/license/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LicenseConfig(AppConfig): + name = "plane.license" diff --git a/apiserver/plane/license/bgtasks/__init__.py b/apiserver/plane/license/bgtasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/__init__.py b/apiserver/plane/license/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/__init__.py b/apiserver/plane/license/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py new file mode 100644 index 000000000..67137d0d9 --- /dev/null +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -0,0 +1,132 @@ +# Python imports +import os + +# Django imports +from django.core.management.base import BaseCommand +from django.conf import settings + +# Module imports +from plane.license.models import InstanceConfiguration + + +class Command(BaseCommand): + help = "Configure instance variables" + + def handle(self, *args, **options): + from plane.license.utils.encryption import encrypt_data + + config_keys = [ + # Authentication Settings + { + "key": "ENABLE_SIGNUP", + "value": os.environ.get("ENABLE_SIGNUP", "1"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + "category": "AUTHENTICATION", + "is_encrypted": False, + }, + { + "key": "GOOGLE_CLIENT_ID", + "value": os.environ.get("GOOGLE_CLIENT_ID"), + "category": "GOOGLE", + "is_encrypted": False, + }, + { + "key": "GITHUB_CLIENT_ID", + "value": os.environ.get("GITHUB_CLIENT_ID"), + "category": "GITHUB", + "is_encrypted": False, + }, + { + "key": "GITHUB_CLIENT_SECRET", + "value": os.environ.get("GITHUB_CLIENT_SECRET"), + "category": "GITHUB", + "is_encrypted": True, + }, + { + "key": "EMAIL_HOST", + "value": os.environ.get("EMAIL_HOST", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_USER", + "value": os.environ.get("EMAIL_HOST_USER", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_HOST_PASSWORD", + "value": os.environ.get("EMAIL_HOST_PASSWORD", ""), + "category": "SMTP", + "is_encrypted": True, + }, + { + "key": "EMAIL_PORT", + "value": os.environ.get("EMAIL_PORT", "587"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_FROM", + "value": os.environ.get("EMAIL_FROM", ""), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "EMAIL_USE_TLS", + "value": os.environ.get("EMAIL_USE_TLS", "1"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "OPENAI_API_KEY", + "value": os.environ.get("OPENAI_API_KEY"), + "category": "OPENAI", + "is_encrypted": True, + }, + { + "key": "GPT_ENGINE", + "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + "category": "SMTP", + "is_encrypted": False, + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "value": os.environ.get("UNSPLASH_ACESS_KEY", ""), + "category": "UNSPLASH", + "is_encrypted": True, + }, + ] + + for item in config_keys: + obj, created = InstanceConfiguration.objects.get_or_create( + key=item.get("key") + ) + if created: + obj.category = item.get("category") + obj.is_encrypted = item.get("is_encrypted", False) + if item.get("is_encrypted", False): + obj.value = encrypt_data(item.get("value")) + else: + obj.value = item.get("value") + obj.save() + self.stdout.write( + self.style.SUCCESS( + f"{obj.key} loaded with value from environment variable." + ) + ) + else: + self.stdout.write( + self.style.WARNING(f"{obj.key} configuration already exists") + ) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py new file mode 100644 index 000000000..e6cfa7167 --- /dev/null +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -0,0 +1,66 @@ +# Python imports +import json +import requests +import secrets + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.conf import settings + +# Module imports +from plane.license.models import Instance +from plane.db.models import User + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument('machine_signature', type=str, help='Machine signature') + + + def handle(self, *args, **options): + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then register this instance + if instance is None: + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + machine_signature = options.get("machine_signature", "machine-signature") + + if not machine_signature: + raise CommandError("Machine signature is required") + + payload = { + "instance_key": settings.INSTANCE_KEY, + "version": data.get("version", 0.1), + "machine_signature": machine_signature, + "user_count": User.objects.filter(is_bot=False).count(), + } + + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=secrets.token_hex(12), + license_key=None, + api_key=secrets.token_hex(8), + version=payload.get("version"), + last_checked_at=timezone.now(), + user_count=payload.get("user_count", 0), + ) + + self.stdout.write( + self.style.SUCCESS( + f"Instance registered" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Instance already registered" + ) + ) + return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py new file mode 100644 index 000000000..c8b5f1f02 --- /dev/null +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# Generated by Django 4.2.7 on 2023-12-06 06:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Instance', + 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)), + ('instance_name', models.CharField(max_length=255)), + ('whitelist_emails', models.TextField(blank=True, null=True)), + ('instance_id', models.CharField(max_length=25, unique=True)), + ('license_key', models.CharField(blank=True, max_length=256, null=True)), + ('api_key', models.CharField(max_length=16)), + ('version', models.CharField(max_length=10)), + ('last_checked_at', models.DateTimeField()), + ('namespace', models.CharField(blank=True, max_length=50, null=True)), + ('is_telemetry_enabled', models.BooleanField(default=True)), + ('is_support_required', models.BooleanField(default=True)), + ('is_setup_done', models.BooleanField(default=False)), + ('is_signup_screen_visited', models.BooleanField(default=False)), + ('user_count', models.PositiveBigIntegerField(default=0)), + ('is_verified', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance', + 'verbose_name_plural': 'Instances', + 'db_table': 'instances', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceConfiguration', + 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)), + ('key', models.CharField(max_length=100, unique=True)), + ('value', models.TextField(blank=True, default=None, null=True)), + ('category', models.TextField()), + ('is_encrypted', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance Configuration', + 'verbose_name_plural': 'Instance Configurations', + 'db_table': 'instance_configurations', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceAdmin', + 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.PositiveIntegerField(choices=[(20, 'Admin')], default=20)), + ('is_verified', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_owner', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Instance Admin', + 'verbose_name_plural': 'Instance Admins', + 'db_table': 'instance_admins', + 'ordering': ('-created_at',), + 'unique_together': {('instance', 'user')}, + }, + ), + ] diff --git a/apiserver/plane/license/migrations/__init__.py b/apiserver/plane/license/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py new file mode 100644 index 000000000..28f2c4352 --- /dev/null +++ b/apiserver/plane/license/models/__init__.py @@ -0,0 +1 @@ +from .instance import Instance, InstanceAdmin, InstanceConfiguration \ No newline at end of file diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py new file mode 100644 index 000000000..86845c34b --- /dev/null +++ b/apiserver/plane/license/models/instance.py @@ -0,0 +1,73 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from plane.db.models import BaseModel + +ROLE_CHOICES = ( + (20, "Admin"), +) + + +class Instance(BaseModel): + # General informations + instance_name = models.CharField(max_length=255) + whitelist_emails = models.TextField(blank=True, null=True) + instance_id = models.CharField(max_length=25, unique=True) + license_key = models.CharField(max_length=256, null=True, blank=True) + api_key = models.CharField(max_length=16) + version = models.CharField(max_length=10) + # Instnace specifics + last_checked_at = models.DateTimeField() + namespace = models.CharField(max_length=50, blank=True, null=True) + # telemetry and support + is_telemetry_enabled = models.BooleanField(default=True) + is_support_required = models.BooleanField(default=True) + # is setup done + is_setup_done = models.BooleanField(default=False) + # signup screen + is_signup_screen_visited = models.BooleanField(default=False) + # users + user_count = models.PositiveBigIntegerField(default=0) + is_verified = models.BooleanField(default=False) + + class Meta: + verbose_name = "Instance" + verbose_name_plural = "Instances" + db_table = "instances" + ordering = ("-created_at",) + + +class InstanceAdmin(BaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_owner", + ) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) + is_verified = models.BooleanField(default=False) + + class Meta: + unique_together = ["instance", "user"] + verbose_name = "Instance Admin" + verbose_name_plural = "Instance Admins" + db_table = "instance_admins" + ordering = ("-created_at",) + + +class InstanceConfiguration(BaseModel): + # The instance configuration variables + key = models.CharField(max_length=100, unique=True) + value = models.TextField(null=True, blank=True, default=None) + category = models.TextField() + is_encrypted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Instance Configuration" + verbose_name_plural = "Instance Configurations" + db_table = "instance_configurations" + ordering = ("-created_at",) + diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py new file mode 100644 index 000000000..807833a7e --- /dev/null +++ b/apiserver/plane/license/urls.py @@ -0,0 +1,42 @@ +from django.urls import path + +from plane.license.api.views import ( + InstanceEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, + InstanceAdminSignInEndpoint, + SignUpScreenVisitedEndpoint, +) + +urlpatterns = [ + path( + "instances/", + InstanceEndpoint.as_view(), + name="instance", + ), + path( + "instances/admins/", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins//", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/configurations/", + InstanceConfigurationEndpoint.as_view(), + name="instance-configuration", + ), + path( + "instances/admins/sign-in/", + InstanceAdminSignInEndpoint.as_view(), + name="instance-admin-sign-in", + ), + path( + "instances/admins/sign-up-screen-visited/", + SignUpScreenVisitedEndpoint.as_view(), + name="instance-sign-up", + ), +] diff --git a/apiserver/plane/license/utils/__init__.py b/apiserver/plane/license/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py new file mode 100644 index 000000000..bf6c23f9d --- /dev/null +++ b/apiserver/plane/license/utils/encryption.py @@ -0,0 +1,22 @@ +import base64 +import hashlib +from django.conf import settings +from cryptography.fernet import Fernet + + +def derive_key(secret_key): + # Use a key derivation function to get a suitable encryption key + dk = hashlib.pbkdf2_hmac('sha256', secret_key.encode(), b'salt', 100000) + return base64.urlsafe_b64encode(dk) + + +def encrypt_data(data): + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + encrypted_data = cipher_suite.encrypt(data.encode()) + return encrypted_data.decode() # Convert bytes to string + + +def decrypt_data(encrypted_data): + cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) + decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes + return decrypted_data.decode() diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py new file mode 100644 index 000000000..e56525893 --- /dev/null +++ b/apiserver/plane/license/utils/instance_value.py @@ -0,0 +1,71 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.encryption import decrypt_data + + +# Helper function to return value from the passed key +def get_configuration_value(keys): + environment_list = [] + if settings.SKIP_ENV_VAR: + # Get the configurations + instance_configuration = InstanceConfiguration.objects.values( + "key", "value", "is_encrypted" + ) + + for key in keys: + for item in instance_configuration: + if key.get("key") == item.get("key"): + if item.get("is_encrypted", False): + environment_list.append(decrypt_data(item.get("value"))) + else: + environment_list.append(item.get("value")) + + break + else: + environment_list.append(key.get("default")) + else: + # Get the configuration from os + for key in keys: + environment_list.append(os.environ.get(key.get("key"), key.get("default"))) + + return tuple(environment_list) + + +def get_email_configuration(): + return ( + get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + { + "key": "EMAIL_PORT", + "default": os.environ.get("EMAIL_PORT", 587), + }, + { + "key": "EMAIL_USE_TLS", + "default": os.environ.get("EMAIL_USE_TLS", "1"), + }, + { + "key": "EMAIL_FROM", + "default": os.environ.get("EMAIL_FROM", "Team Plane "), + }, + ] + ) + ) + diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py new file mode 100644 index 000000000..a1894fad5 --- /dev/null +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -0,0 +1,40 @@ +from plane.db.models import APIToken, APIActivityLog + + +class APITokenLogMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_body = request.body + response = self.get_response(request) + self.process_request(request, response, request_body) + return response + + def process_request(self, request, response, request_body): + api_key_header = "X-Api-Key" + api_key = request.headers.get(api_key_header) + # If the API key is present, log the request + if api_key: + try: + APIActivityLog.objects.create( + token_identifier=api_key, + path=request.path, + method=request.method, + query_params=request.META.get("QUERY_STRING", ""), + headers=str(request.headers), + body=(request_body.decode('utf-8') if request_body else None), + response_body=( + response.content.decode("utf-8") if response.content else None + ), + response_code=response.status_code, + ip_address=request.META.get("REMOTE_ADDR", None), + user_agent=request.META.get("HTTP_USER_AGENT", None), + ) + + except Exception as e: + print(e) + # If the token does not exist, you can decide whether to log this as an invalid attempt + pass + + return None diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 27da44d9c..76528176b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -1,47 +1,61 @@ +"""Global Settings""" +# Python imports import os -import datetime +import ssl +import certifi from datetime import timedelta +from urllib.parse import urlparse + +# Django imports from django.core.management.utils import get_random_secret_key +# Third party imports +import dj_database_url +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration +from sentry_sdk.integrations.celery import CeleryIntegration BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - +# Secret Key SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] +DEBUG = False +# Allowed Hosts +ALLOWED_HOSTS = ["*"] # Application definition - INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", # Inhouse apps "plane.analytics", - "plane.api", + "plane.app", + "plane.space", "plane.bgtasks", "plane.db", "plane.utils", "plane.web", "plane.middleware", + "plane.license", + "plane.api", # Third-party things "rest_framework", "rest_framework.authtoken", "rest_framework_simplejwt.token_blacklist", "corsheaders", - "taggit", "django_celery_beat", + "storages", ] +# Middlewares MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", - # "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -49,8 +63,10 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", - ] + "plane.middleware.api_log_middleware.APITokenLogMiddleware", +] +# Rest Framework settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", @@ -60,13 +76,13 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), } -AUTHENTICATION_BACKENDS = ( - "django.contrib.auth.backends.ModelBackend", # default - # "guardian.backends.ObjectPermissionBackend", -) +# Django Auth Backend +AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default +# Root Urls ROOT_URLCONF = "plane.urls" +# Templates TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -85,52 +101,76 @@ TEMPLATES = [ }, ] +# Cookie Settings +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True -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, -} +# CORS Settings +CORS_ALLOW_CREDENTIALS = True +cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") +# filter out empty strings +cors_allowed_origins = [ + origin.strip() for origin in cors_origins_raw.split(",") if origin.strip() +] +if cors_allowed_origins: + CORS_ALLOWED_ORIGINS = cors_allowed_origins +else: + CORS_ALLOW_ALL_ORIGINS = True +# Application Settings WSGI_APPLICATION = "plane.wsgi.application" ASGI_APPLICATION = "plane.asgi.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"), +if bool(os.environ.get("DATABASE_URL")): + # Parse database configuration from $DATABASE_URL + DATABASES = { + "default": dj_database_url.config(), + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_HOST"), + } } -} +# Redis Config +REDIS_URL = os.environ.get("REDIS_URL") +REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL -# Password validation +if REDIS_SSL: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } + } +# Password validations AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -146,8 +186,10 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -# Static files (CSS, JavaScript, Images) +# Password reset time the number of seconds the uniquely generated uid will be valid +PASSWORD_RESET_TIMEOUT = 3600 +# 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"),) @@ -156,36 +198,49 @@ STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) MEDIA_ROOT = "mediafiles" MEDIA_URL = "/media/" - # Internationalization - LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - USE_I18N = True - USE_L10N = True +# Timezones USE_TZ = True +TIME_ZONE = "UTC" +# Default Auto Field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Email settings 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 = int(os.environ.get("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 = os.environ.get("EMAIL_USE_TLS", "1") == "1" -EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1" -EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane ") + +# Storage Settings +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} +STORAGES["default"] = { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", +} +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") +AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") +AWS_REGION = os.environ.get("AWS_REGION", "") +AWS_DEFAULT_ACL = "public-read" +AWS_QUERYSTRING_AUTH = False +AWS_S3_FILE_OVERWRITE = False +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get( + "MINIO_ENDPOINT_URL", None +) +if AWS_S3_ENDPOINT_URL: + parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) + AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" + AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" +# JWT Auth Configuration SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=43200), "REFRESH_TOKEN_LIFETIME": timedelta(days=43200), "ROTATE_REFRESH_TOKENS": False, "BLACKLIST_AFTER_ROTATION": False, @@ -211,7 +266,71 @@ SIMPLE_JWT = { "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), } + +# Celery Configuration CELERY_TIMEZONE = TIME_ZONE -CELERY_TASK_SERIALIZER = 'json' -CELERY_ACCEPT_CONTENT = ['application/json'] -CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task") +CELERY_TASK_SERIALIZER = "json" +CELERY_ACCEPT_CONTENT = ["application/json"] + +if REDIS_SSL: + redis_url = os.environ.get("REDIS_URL") + broker_url = ( + f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" + ) + CELERY_BROKER_URL = broker_url + CELERY_RESULT_BACKEND = broker_url +else: + CELERY_BROKER_URL = REDIS_URL + CELERY_RESULT_BACKEND = REDIS_URL + +CELERY_IMPORTS = ( + "plane.bgtasks.issue_automation_task", + "plane.bgtasks.exporter_expired_task", + "plane.bgtasks.file_asset_task", +) + +# Sentry Settings +# Enable Sentry Settings +if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"): + sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN", ""), + integrations=[ + DjangoIntegration(), + RedisIntegration(), + CeleryIntegration(monitor_beat_tasks=True), + ], + traces_sample_rate=1, + send_default_pii=True, + environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), + profiles_sample_rate=1.0, + ) + + +# Application Envs +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External +SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") +# Github Access Token +GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) + +# Analytics +ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) +ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) + +# Use Minio settings +USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 + +# Posthog settings +POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) +POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) + +# instance key +INSTANCE_KEY = os.environ.get( + "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" +) + +# Skip environment variable configuration +SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 76586b0fe..8f27d4234 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -1,123 +1,31 @@ -"""Development settings and globals.""" - -from __future__ import absolute_import - -import dj_database_url -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.redis import RedisIntegration - - +"""Development settings""" from .common import * # noqa -DEBUG = int(os.environ.get("DEBUG", 1)) == 1 +DEBUG = True -ALLOWED_HOSTS = [ - "*", -] +# Debug Toolbar settings +INSTALLED_APPS += ("debug_toolbar",) +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) +DEBUG_TOOLBAR_PATCH_SETTINGS = False + +# Only show emails in console don't send it to smtp EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("PGUSER", "plane"), - "USER": "", - "PASSWORD": "", - "HOST": os.environ.get("PGHOST", "localhost"), - } -} - -DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 - -USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 - -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) - -if DOCKERIZED: - DATABASES["default"] = dj_database_url.config() - 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 - -if os.environ.get("SENTRY_DSN", False): - 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, - profiles_sample_rate=1.0, - ) -else: - LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "DEBUG", - }, - "loggers": { - "*": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": True, - }, - }, - } - -REDIS_HOST = "localhost" -REDIS_PORT = 6379 -REDIS_URL = os.environ.get("REDIS_URL") - - MEDIA_URL = "/uploads/" MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") -if DOCKERIZED: - REDIS_URL = os.environ.get("REDIS_URL") - -WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000") -PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) - -ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) -ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) - -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - -SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) - -LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False) - -CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL") -CELERY_BROKER_URL = os.environ.get("REDIS_URL") - -GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) - -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" - -# Unsplash Access key -UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:4000", + "http://127.0.0.1:4000", +] diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 541a0cfd4..90eb04dd5 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,282 +1,18 @@ -"""Production settings and globals.""" -import ssl -import certifi - -import dj_database_url - -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.redis import RedisIntegration -from urllib.parse import urlparse - +"""Production settings""" from .common import * # noqa -# Database +# SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", 0)) == 1 -if bool(os.environ.get("DATABASE_URL")): - # Parse database configuration from $DATABASE_URL - DATABASES["default"] = dj_database_url.config() -else: - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB"), - "USER": os.environ.get("POSTGRES_USER"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), - "HOST": os.environ.get("POSTGRES_HOST"), - } - } - - -SITE_ID = 1 - -# Set the variable true if running in docker environment -DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 - -USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 - -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) - -# 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") - -# TODO: Make it FALSE and LIST DOMAINS IN FULL PROD. -CORS_ALLOW_ALL_ORIGINS = True - - -CORS_ALLOW_METHODS = [ - "DELETE", - "GET", - "OPTIONS", - "PATCH", - "POST", - "PUT", -] - -CORS_ALLOW_HEADERS = [ - "accept", - "accept-encoding", - "authorization", - "content-type", - "dnt", - "origin", - "user-agent", - "x-csrftoken", - "x-requested-with", -] - -CORS_ALLOW_CREDENTIALS = True - INSTALLED_APPS += ("scout_apm.django",) -STORAGES = { - "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} - -if bool(os.environ.get("SENTRY_DSN", False)): - 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", - profiles_sample_rate=1.0, - ) - -if DOCKERIZED and USE_MINIO: - INSTALLED_APPS += ("storages",) - STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} - # The AWS access key to use. - AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") - # The AWS secret access key to use. - AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") - # The name of the bucket to store files in. - AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") - # The full URL to the S3 endpoint. Leave blank to use the default region URL. - AWS_S3_ENDPOINT_URL = os.environ.get( - "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" - ) - # Default permissions - AWS_DEFAULT_ACL = "public-read" - AWS_QUERYSTRING_AUTH = False - AWS_S3_FILE_OVERWRITE = False - - # Custom Domain settings - parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) - AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" - AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" -else: - # 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 = os.environ.get("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 - - STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", - } -# 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 = [ - "*", -] - - -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - - -REDIS_URL = os.environ.get("REDIS_URL") - -if DOCKERIZED: - CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } - } -else: - CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, - }, - } - } - - -WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so") - -PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) - -ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) -ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) - -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - -SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) - -LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False) - -redis_url = os.environ.get("REDIS_URL") -broker_url = ( - f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" -) - -if DOCKERIZED: - CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL -else: - CELERY_BROKER_URL = broker_url - CELERY_RESULT_BACKEND = broker_url - -GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) - -# Enable or Disable signups -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" - # Scout Settings SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" - -# Unsplash Access key -UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py index 4e906c4a1..5b09a1277 100644 --- a/apiserver/plane/settings/redis.py +++ b/apiserver/plane/settings/redis.py @@ -6,13 +6,7 @@ from urllib.parse import urlparse def redis_instance(): # connect to redis - if ( - settings.DOCKERIZED - or os.environ.get("DJANGO_SETTINGS_MODULE", "plane.settings.production") - == "plane.settings.local" - ): - ri = redis.Redis.from_url(settings.REDIS_URL, db=0) - else: + if settings.REDIS_SSL: url = urlparse(settings.REDIS_URL) ri = redis.Redis( host=url.hostname, @@ -21,5 +15,7 @@ def redis_instance(): ssl=True, ssl_cert_reqs=None, ) + else: + ri = redis.Redis.from_url(settings.REDIS_URL, db=0) return ri diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py deleted file mode 100644 index ee529a7c3..000000000 --- a/apiserver/plane/settings/selfhosted.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Self hosted settings and globals.""" -from urllib.parse import urlparse - -import dj_database_url -from urllib.parse import urlparse - - -from .common import * # noqa - -# Database -DEBUG = int(os.environ.get("DEBUG", 0)) == 1 - -# Docker configurations -DOCKERIZED = 1 -USE_MINIO = 1 - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "plane", - "USER": os.environ.get("PGUSER", ""), - "PASSWORD": os.environ.get("PGPASSWORD", ""), - "HOST": os.environ.get("PGHOST", ""), - } -} - -# Parse database configuration from $DATABASE_URL -DATABASES["default"] = dj_database_url.config() -SITE_ID = 1 - -# File size limit -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) - -CORS_ALLOW_METHODS = [ - "DELETE", - "GET", - "OPTIONS", - "PATCH", - "POST", - "PUT", -] - -CORS_ALLOW_HEADERS = [ - "accept", - "accept-encoding", - "authorization", - "content-type", - "dnt", - "origin", - "user-agent", - "x-csrftoken", - "x-requested-with", -] - -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_ALL_ORIGINS = True - -STORAGES = { - "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} - -INSTALLED_APPS += ("storages",) -STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} -# The AWS access key to use. -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") -# The AWS secret access key to use. -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") -# The name of the bucket to store files in. -AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") -# The full URL to the S3 endpoint. Leave blank to use the default region URL. -AWS_S3_ENDPOINT_URL = os.environ.get( - "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" -) -# Default permissions -AWS_DEFAULT_ACL = "public-read" -AWS_QUERYSTRING_AUTH = False -AWS_S3_FILE_OVERWRITE = False - -# Custom Domain settings -parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) -AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" -AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" - -# 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 = [ - "*", -] - -# Security settings -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - -# Redis URL -REDIS_URL = os.environ.get("REDIS_URL") - -# Caches -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } -} - -# URL used for email redirects -WEB_URL = os.environ.get("WEB_URL", "http://localhost") - -# Celery settings -CELERY_BROKER_URL = REDIS_URL -CELERY_RESULT_BACKEND = REDIS_URL - -# Enable or Disable signups -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" - -# Analytics -ANALYTICS_BASE_API = False - -# OPEN AI Settings -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py deleted file mode 100644 index fe4732343..000000000 --- a/apiserver/plane/settings/staging.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Production settings and globals.""" -from urllib.parse import urlparse -import ssl -import certifi - -import dj_database_url - -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.redis import RedisIntegration - -from .common import * # noqa - -# Database -DEBUG = int(os.environ.get("DEBUG", 1)) == 1 -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("PGUSER", "plane"), - "USER": "", - "PASSWORD": "", - "HOST": os.environ.get("PGHOST", "localhost"), - } -} - -# 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 - -STORAGES = { - "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} - - -# Make true if running in a docker environment -DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1 -FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) -USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 - -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", - profiles_sample_rate=1.0, -) - -# 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 = os.environ.get("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 -STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", -} - -# 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 = [ - "*", -] - -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - - -REDIS_URL = os.environ.get("REDIS_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") - -PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) - -ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) -ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) - - -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - -SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) - -LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False) - -redis_url = os.environ.get("REDIS_URL") -broker_url = ( - f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" -) - -CELERY_RESULT_BACKEND = broker_url -CELERY_BROKER_URL = broker_url - -GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) - -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" - - -# Unsplash Access key -UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py index 6c009997c..34ae16555 100644 --- a/apiserver/plane/settings/test.py +++ b/apiserver/plane/settings/test.py @@ -1,45 +1,9 @@ -from __future__ import absolute_import - +"""Test Settings""" from .common import * # noqa DEBUG = True -INSTALLED_APPS.append("plane.tests") +# Send it in a dummy outbox +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" -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" +INSTALLED_APPS.append("plane.tests",) diff --git a/apiserver/plane/space/__init__.py b/apiserver/plane/space/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/space/apps.py b/apiserver/plane/space/apps.py new file mode 100644 index 000000000..6f1e76c51 --- /dev/null +++ b/apiserver/plane/space/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SpaceConfig(AppConfig): + name = "plane.space" diff --git a/apiserver/plane/space/serializer/__init__.py b/apiserver/plane/space/serializer/__init__.py new file mode 100644 index 000000000..cd10fb5c6 --- /dev/null +++ b/apiserver/plane/space/serializer/__init__.py @@ -0,0 +1,5 @@ +from .user import UserLiteSerializer + +from .issue import LabelLiteSerializer, StateLiteSerializer + +from .state import StateSerializer, StateLiteSerializer diff --git a/apiserver/plane/space/serializer/base.py b/apiserver/plane/space/serializer/base.py new file mode 100644 index 000000000..89c9725d9 --- /dev/null +++ b/apiserver/plane/space/serializer/base.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + + +class BaseSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(read_only=True) + +class DynamicBaseSerializer(BaseSerializer): + + def __init__(self, *args, **kwargs): + # If 'fields' is provided in the arguments, remove it and store it separately. + # This is done so as not to pass this custom argument up to the superclass. + fields = kwargs.pop("fields", None) + + # Call the initialization of the superclass. + super().__init__(*args, **kwargs) + + # If 'fields' was provided, filter the fields of the serializer accordingly. + if fields is not None: + self.fields = self._filter_fields(fields) + + def _filter_fields(self, fields): + """ + Adjust the serializer's fields based on the provided 'fields' list. + + :param fields: List or dictionary specifying which fields to include in the serializer. + :return: The updated fields for the serializer. + """ + # Check each field_name in the provided fields. + for field_name in fields: + # If the field is a dictionary (indicating nested fields), + # loop through its keys and values. + if isinstance(field_name, dict): + for key, value in field_name.items(): + # If the value of this nested field is a list, + # perform a recursive filter on it. + if isinstance(value, list): + self._filter_fields(self.fields[key], value) + + # Create a list to store allowed fields. + allowed = [] + for item in fields: + # If the item is a string, it directly represents a field's name. + if isinstance(item, str): + allowed.append(item) + # If the item is a dictionary, it represents a nested field. + # Add the key of this dictionary to the allowed list. + elif isinstance(item, dict): + allowed.append(list(item.keys())[0]) + + # Convert the current serializer's fields and the allowed fields to sets. + existing = set(self.fields) + allowed = set(allowed) + + # Remove fields from the serializer that aren't in the 'allowed' list. + for field_name in (existing - allowed): + self.fields.pop(field_name) + + return self.fields diff --git a/apiserver/plane/space/serializer/cycle.py b/apiserver/plane/space/serializer/cycle.py new file mode 100644 index 000000000..ab4d9441d --- /dev/null +++ b/apiserver/plane/space/serializer/cycle.py @@ -0,0 +1,18 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + Cycle, +) + +class CycleBaseSerializer(BaseSerializer): + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] \ No newline at end of file diff --git a/apiserver/plane/space/serializer/inbox.py b/apiserver/plane/space/serializer/inbox.py new file mode 100644 index 000000000..05d99ac55 --- /dev/null +++ b/apiserver/plane/space/serializer/inbox.py @@ -0,0 +1,47 @@ +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer +from plane.db.models import ( + Issue, + InboxIssue, +) + + +class InboxIssueSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = InboxIssue + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueLiteSerializer(BaseSerializer): + class Meta: + model = InboxIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +class IssueStateInboxSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py new file mode 100644 index 000000000..1a9a872ef --- /dev/null +++ b/apiserver/plane/space/serializer/issue.py @@ -0,0 +1,506 @@ + +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateSerializer, StateLiteSerializer +from .project import ProjectLiteSerializer +from .cycle import CycleBaseSerializer +from .module import ModuleBaseSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueComment, + IssueAssignee, + IssueLabel, + Label, + CycleIssue, + ModuleIssue, + IssueLink, + IssueAttachment, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, +) + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = [ + "id", + "sequence_id", + "name", + "state_detail", + "project_detail", + ] + + +class LabelSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Label + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + ] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = [ + "id", + "project_detail", + "name", + "sequence_id", + ] + read_only_fields = fields + + +class IssueRelationSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + + class Meta: + model = IssueRelation + fields = [ + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", + ] + +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + + class Meta: + model = IssueRelation + fields = [ + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", + ] + + +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 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 IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) + + +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = IssueAttachment + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueReactionSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "actor", + ] + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateSerializer(read_only=True, source="state") + parent_detail = IssueStateFlatSerializer(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) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) + issue_cycle = IssueCycleDetailSerializer(read_only=True) + issue_module = IssueModuleDetailSerializer(read_only=True) + issue_link = IssueLinkSerializer(read_only=True, many=True) + issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class CommentReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = CommentReaction + fields = [ + "id", + "reaction", + "comment", + "actor_detail", + ] + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +##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 = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] + data['labels'] = [str(label.id) for label in instance.labels.all()] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + else: + # Then assign it to default assignee + if default_assignee_id is not None: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if labels is not None and len(labels): + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + 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_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class IssueReactionSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "actor", + ] + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor"] + + +class IssueVoteSerializer(BaseSerializer): + + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + read_only_fields = fields + + +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + votes = IssueVoteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + ] + read_only_fields = fields + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + + + diff --git a/apiserver/plane/space/serializer/module.py b/apiserver/plane/space/serializer/module.py new file mode 100644 index 000000000..39ce9ec32 --- /dev/null +++ b/apiserver/plane/space/serializer/module.py @@ -0,0 +1,18 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + Module, +) + +class ModuleBaseSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] \ No newline at end of file diff --git a/apiserver/plane/space/serializer/project.py b/apiserver/plane/space/serializer/project.py new file mode 100644 index 000000000..be23e0ce2 --- /dev/null +++ b/apiserver/plane/space/serializer/project.py @@ -0,0 +1,20 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + Project, +) + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "icon_prop", + "emoji", + "description", + ] + read_only_fields = fields diff --git a/apiserver/plane/space/serializer/state.py b/apiserver/plane/space/serializer/state.py new file mode 100644 index 000000000..903bcc2f4 --- /dev/null +++ b/apiserver/plane/space/serializer/state.py @@ -0,0 +1,28 @@ +# 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", + ] + + +class StateLiteSerializer(BaseSerializer): + class Meta: + model = State + fields = [ + "id", + "name", + "color", + "group", + ] + read_only_fields = fields diff --git a/apiserver/plane/space/serializer/user.py b/apiserver/plane/space/serializer/user.py new file mode 100644 index 000000000..e206073f7 --- /dev/null +++ b/apiserver/plane/space/serializer/user.py @@ -0,0 +1,22 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + User, +) + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "is_bot", + "display_name", + ] + read_only_fields = [ + "id", + "is_bot", + ] diff --git a/apiserver/plane/space/serializer/workspace.py b/apiserver/plane/space/serializer/workspace.py new file mode 100644 index 000000000..ecf99079f --- /dev/null +++ b/apiserver/plane/space/serializer/workspace.py @@ -0,0 +1,15 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + Workspace, +) + +class WorkspaceLiteSerializer(BaseSerializer): + class Meta: + model = Workspace + fields = [ + "name", + "slug", + "id", + ] + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/space/urls/__init__.py b/apiserver/plane/space/urls/__init__.py new file mode 100644 index 000000000..054026b00 --- /dev/null +++ b/apiserver/plane/space/urls/__init__.py @@ -0,0 +1,10 @@ +from .inbox import urlpatterns as inbox_urls +from .issue import urlpatterns as issue_urls +from .project import urlpatterns as project_urls + + +urlpatterns = [ + *inbox_urls, + *issue_urls, + *project_urls, +] diff --git a/apiserver/plane/space/urls/inbox.py b/apiserver/plane/space/urls/inbox.py new file mode 100644 index 000000000..60de040e2 --- /dev/null +++ b/apiserver/plane/space/urls/inbox.py @@ -0,0 +1,49 @@ +from django.urls import path + + +from plane.space.views import ( + InboxIssuePublicViewSet, + IssueVotePublicViewSet, + WorkspaceProjectDeployBoardEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//project-boards//inboxes//inbox-issues/", + InboxIssuePublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox-issue", + ), + path( + "workspaces//project-boards//inboxes//inbox-issues//", + InboxIssuePublicViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox-issue", + ), + path( + "workspaces//project-boards//issues//votes/", + IssueVotePublicViewSet.as_view( + { + "get": "list", + "post": "create", + "delete": "destroy", + } + ), + name="issue-vote-project-board", + ), + path( + "workspaces//project-boards/", + WorkspaceProjectDeployBoardEndpoint.as_view(), + name="workspace-project-boards", + ), +] diff --git a/apiserver/plane/space/urls/issue.py b/apiserver/plane/space/urls/issue.py new file mode 100644 index 000000000..099eace5d --- /dev/null +++ b/apiserver/plane/space/urls/issue.py @@ -0,0 +1,76 @@ +from django.urls import path + + +from plane.space.views import ( + IssueRetrievePublicEndpoint, + IssueCommentPublicViewSet, + IssueReactionPublicViewSet, + CommentReactionPublicViewSet, +) + +urlpatterns = [ + path( + "workspaces//project-boards//issues//", + IssueRetrievePublicEndpoint.as_view(), + name="workspace-project-boards", + ), + path( + "workspaces//project-boards//issues//comments/", + IssueCommentPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="issue-comments-project-board", + ), + path( + "workspaces//project-boards//issues//comments//", + IssueCommentPublicViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="issue-comments-project-board", + ), + path( + "workspaces//project-boards//issues//reactions/", + IssueReactionPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="issue-reactions-project-board", + ), + path( + "workspaces//project-boards//issues//reactions//", + IssueReactionPublicViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-reactions-project-board", + ), + path( + "workspaces//project-boards//comments//reactions/", + CommentReactionPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="comment-reactions-project-board", + ), + path( + "workspaces//project-boards//comments//reactions//", + CommentReactionPublicViewSet.as_view( + { + "delete": "destroy", + } + ), + name="comment-reactions-project-board", + ), +] diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py new file mode 100644 index 000000000..dc97b43a7 --- /dev/null +++ b/apiserver/plane/space/urls/project.py @@ -0,0 +1,20 @@ +from django.urls import path + + +from plane.space.views import ( + ProjectDeployBoardPublicSettingsEndpoint, + ProjectIssuesPublicEndpoint, +) + +urlpatterns = [ + path( + "workspaces//project-boards//settings/", + ProjectDeployBoardPublicSettingsEndpoint.as_view(), + name="project-deploy-board-settings", + ), + path( + "workspaces//project-boards//issues/", + ProjectIssuesPublicEndpoint.as_view(), + name="project-deploy-board", + ), +] diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py new file mode 100644 index 000000000..5130e04d5 --- /dev/null +++ b/apiserver/plane/space/views/__init__.py @@ -0,0 +1,15 @@ +from .project import ( + ProjectDeployBoardPublicSettingsEndpoint, + WorkspaceProjectDeployBoardEndpoint, +) + +from .issue import ( + IssueCommentPublicViewSet, + IssueReactionPublicViewSet, + CommentReactionPublicViewSet, + IssueVotePublicViewSet, + IssueRetrievePublicEndpoint, + ProjectIssuesPublicEndpoint, +) + +from .inbox import InboxIssuePublicViewSet diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py new file mode 100644 index 000000000..b1d749a09 --- /dev/null +++ b/apiserver/plane/space/views/base.py @@ -0,0 +1,212 @@ +# Python imports +import zoneinfo + +# Django imports +from django.urls import resolve +from django.conf import settings +from django.utils import timezone +from django.db import IntegrityError +from django.core.exceptions import ObjectDoesNotExist, ValidationError + +# Third part imports +from rest_framework import status +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +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 sentry_sdk import capture_exception +from django_filters.rest_framework import DjangoFilterBackend + +# Module imports +from plane.utils.paginator import BasePaginator + + +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + +class BaseViewSet(TimezoneMixin, 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: + capture_exception(e) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + model_name = str(exc).split(" matching query does not exist.")[0] + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + capture_exception(e) + return Response( + {"error": f"key {e} does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + print(e) if settings.DEBUG else print("Server Error") + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) + + return response + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", 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) + + +class BaseAPIView(TimezoneMixin, 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 handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + try: + response = super().handle_exception(exc) + return response + except Exception as e: + if isinstance(e, IntegrityError): + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ValidationError): + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if isinstance(e, ObjectDoesNotExist): + model_name = str(exc).split(" matching query does not exist.")[0] + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if isinstance(e, KeyError): + return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + + if settings.DEBUG: + print(e) + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + def dispatch(self, request, *args, **kwargs): + try: + response = super().dispatch(request, *args, **kwargs) + + if settings.DEBUG: + from django.db import connection + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) + return response + + except Exception as exc: + response = self.handle_exception(exc) + return exc + + @property + def workspace_slug(self): + return self.kwargs.get("slug", None) + + @property + def project_id(self): + return self.kwargs.get("project_id", None) diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py new file mode 100644 index 000000000..53960f672 --- /dev/null +++ b/apiserver/plane/space/views/inbox.py @@ -0,0 +1,282 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseViewSet +from plane.db.models import ( + InboxIssue, + Issue, + State, + IssueLink, + IssueAttachment, + ProjectDeployBoard, +) +from plane.app.serializers import ( + IssueSerializer, + InboxIssueSerializer, + IssueCreateSerializer, + IssueStateInboxSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activites_task import issue_activity + + +class InboxIssuePublicViewSet(BaseViewSet): + serializer_class = InboxIssueSerializer + model = InboxIssue + + filterset_fields = [ + "status", + ] + + def get_queryset(self): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board is not None: + return self.filter_queryset( + super() + .get_queryset() + .filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + inbox_id=self.kwargs.get("inbox_id"), + ) + .select_related("issue", "workspace", "project") + ) + return InboxIssue.objects.none() + + def list(self, request, slug, project_id, inbox_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if project_deploy_board.inbox is None: + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter( + issue_inbox__inbox_id=inbox_id, + workspace__slug=slug, + project_id=project_id, + ) + .filter(**filters) + .annotate(bridge_id=F("issue_inbox__id")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + ) + issues_data = IssueStateInboxSerializer(issues, many=True).data + return Response( + issues_data, + status=status.HTTP_200_OK, + ) + + def create(self, request, slug, project_id, inbox_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if project_deploy_board.inbox is None: + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not request.data.get("issue", {}).get("name", False): + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Check for valid priority + if not request.data.get("issue", {}).get("priority", "none") in [ + "low", + "medium", + "high", + "urgent", + "none", + ]: + return Response( + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Create or get state + state, _ = State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=project_id, + color="#ff7700", + ) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get( + "description_html", "

" + ), + priority=request.data.get("issue", {}).get("priority", "low"), + project_id=project_id, + state=state, + ) + + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # create an inbox issue + InboxIssue.objects.create( + inbox_id=inbox_id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) + + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, slug, project_id, inbox_id, pk): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if project_deploy_board.inbox is None: + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + # Get the project member + if str(inbox_issue.created_by_id) != str(request.user.id): + return Response( + {"error": "You cannot edit inbox issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get issue data + issue_data = request.data.pop("issue", False) + + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + # viewers and guests since only viewers and guests + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get( + "description_html", issue.description_html + ), + "description": issue_data.get("description", issue.description), + } + + issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + issue_serializer.save() + return Response(issue_serializer.data, status=status.HTTP_200_OK) + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, slug, project_id, inbox_id, pk): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if project_deploy_board.inbox is None: + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, inbox_id, pk): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if project_deploy_board.inbox is None: + return Response( + {"error": "Inbox is not enabled for this Project Board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + + if str(inbox_issue.created_by_id) != str(request.user.id): + return Response( + {"error": "You cannot delete inbox issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + inbox_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py new file mode 100644 index 000000000..faab8834d --- /dev/null +++ b/apiserver/plane/space/views/issue.py @@ -0,0 +1,656 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.db.models import ( + Prefetch, + OuterRef, + Func, + F, + Q, + Count, + Case, + Value, + CharField, + When, + Exists, + Max, + IntegerField, +) +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.app.serializers import ( + IssueCommentSerializer, + IssueReactionSerializer, + CommentReactionSerializer, + IssueVoteSerializer, + IssuePublicSerializer, +) + +from plane.db.models import ( + Issue, + IssueComment, + Label, + IssueLink, + IssueAttachment, + State, + ProjectMember, + IssueReaction, + CommentReaction, + ProjectDeployBoard, + IssueVote, + ProjectPublicMember, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results +from plane.utils.issue_filters import issue_filters + + +class IssueCommentPublicViewSet(BaseViewSet): + serializer_class = IssueCommentSerializer + model = IssueComment + + filterset_fields = [ + "issue__id", + "workspace__id", + ] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + self.permission_classes = [ + AllowAny, + ] + else: + self.permission_classes = [ + IsAuthenticated, + ] + + return super(IssueCommentPublicViewSet, self).get_permissions() + + def get_queryset(self): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board.comments: + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(access="EXTERNAL") + .select_related("project") + .select_related("workspace") + .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .distinct() + ).order_by("created_at") + return IssueComment.objects.none() + except ProjectDeployBoard.DoesNotExist: + return IssueComment.objects.none() + + def create(self, request, slug, project_id, issue_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.comments: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + issue_id=issue_id, + actor=request.user, + access="EXTERNAL", + ) + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, project_id, issue_id, pk): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.comments: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + comment = IssueComment.objects.get( + workspace__slug=slug, pk=pk, actor=request.user + ) + serializer = IssueCommentSerializer(comment, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps( + IssueCommentSerializer(comment).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, pk): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.comments: + return Response( + {"error": "Comments are not enabled for this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + comment = IssueComment.objects.get( + workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user + ) + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=json.dumps( + IssueCommentSerializer(comment).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + ) + comment.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueReactionPublicViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + + def get_queryset(self): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board.reactions: + return ( + 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")) + .order_by("-created_at") + .distinct() + ) + return IssueReaction.objects.none() + except ProjectDeployBoard.DoesNotExist: + return IssueReaction.objects.none() + + def create(self, request, slug, project_id, issue_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.reactions: + return Response( + {"error": "Reactions are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, issue_id=issue_id, actor=request.user + ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, issue_id, reaction_code): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.reactions: + return Response( + {"error": "Reactions are not enabled for this project board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_reaction = IssueReaction.objects.get( + workspace__slug=slug, + issue_id=issue_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + issue_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentReactionPublicViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + + def get_queryset(self): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board.reactions: + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(comment_id=self.kwargs.get("comment_id")) + .order_by("-created_at") + .distinct() + ) + return CommentReaction.objects.none() + except ProjectDeployBoard.DoesNotExist: + return CommentReaction.objects.none() + + def create(self, request, slug, project_id, comment_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + if not project_deploy_board.reactions: + return Response( + {"error": "Reactions are not enabled for this board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, comment_id=comment_id, actor=request.user + ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, project_id, comment_id, reaction_code): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + if not project_deploy_board.reactions: + return Response( + {"error": "Reactions are not enabled for this board"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + comment_reaction = CommentReaction.objects.get( + project_id=project_id, + workspace__slug=slug, + comment_id=comment_id, + reaction=reaction_code, + actor=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueVotePublicViewSet(BaseViewSet): + model = IssueVote + serializer_class = IssueVoteSerializer + + def get_queryset(self): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + if project_deploy_board.votes: + return ( + super() + .get_queryset() + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + ) + return IssueVote.objects.none() + except ProjectDeployBoard.DoesNotExist: + return IssueVote.objects.none() + + def create(self, request, slug, project_id, issue_id): + issue_vote, _ = IssueVote.objects.get_or_create( + actor_id=request.user.id, + project_id=project_id, + issue_id=issue_id, + ) + # Add the user for workspace tracking + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + ).exists(): + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_vote.vote = request.data.get("vote", 1) + issue_vote.save() + issue_activity.delay( + type="issue_vote.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + serializer = IssueVoteSerializer(issue_vote) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, slug, project_id, issue_id): + issue_vote = IssueVote.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + actor_id=request.user.id, + ) + issue_activity.delay( + type="issue_vote.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "vote": str(issue_vote.vote), + "identifier": str(issue_vote.id), + } + ), + epoch=int(timezone.now().timestamp()), + ) + issue_vote.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueRetrievePublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id, issue_id): + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=issue_id + ) + serializer = IssuePublicSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProjectIssuesPublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project", "workspace", "state", "parent") + .prefetch_related("assignees", "labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .prefetch_related( + Prefetch( + "votes", + queryset=IssueVote.objects.select_related("actor"), + ) + ) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssuePublicSerializer(issue_queryset, many=True).data + + state_group_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + states = ( + State.objects.filter( + ~Q(name="Triage"), + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + custom_order=Case( + *[ + When(group=value, then=Value(index)) + for index, value in enumerate(state_group_order) + ], + default=Value(len(state_group_order)), + output_field=IntegerField(), + ), + ) + .values("name", "group", "color", "id") + .order_by("custom_order", "sequence") + ) + + labels = Label.objects.filter( + workspace__slug=slug, project_id=project_id + ).values("id", "name", "color", "parent") + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + issues = group_results(issues, group_by) + + return Response( + { + "issues": issues, + "states": states, + "labels": labels, + }, + status=status.HTTP_200_OK, + ) \ No newline at end of file diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py new file mode 100644 index 000000000..8cd3f55c5 --- /dev/null +++ b/apiserver/plane/space/views/project.py @@ -0,0 +1,61 @@ +# Django imports +from django.db.models import ( + Exists, + OuterRef, +) + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.app.serializers import ProjectDeployBoardSerializer +from plane.app.permissions import ProjectMemberPermission +from plane.db.models import ( + Project, + ProjectDeployBoard, +) + + +class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + serializer = ProjectDeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug): + projects = ( + Project.objects.filter(workspace__slug=slug) + .annotate( + is_public=Exists( + ProjectDeployBoard.objects.filter( + workspace__slug=slug, project_id=OuterRef("pk") + ) + ) + ) + .filter(is_public=True) + ).values( + "id", + "identifier", + "name", + "description", + "emoji", + "icon_prop", + "cover_image", + ) + + return Response(projects, status=status.HTTP_200_OK) diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py index fec51303a..e3209a281 100644 --- a/apiserver/plane/tests/api/base.py +++ b/apiserver/plane/tests/api/base.py @@ -3,7 +3,7 @@ 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 +from plane.app.views.authentication import get_tokens_for_user class BaseAPITest(APITestCase): diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index 90643749c..75b4c2609 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -10,7 +10,10 @@ from django.conf import settings urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), - path("api/", include("plane.api.urls")), + path("api/", include("plane.app.urls")), + path("api/public/", include("plane.space.urls")), + path("api/licenses/", include("plane.license.urls")), + path("api/v1/", include("plane.api.urls")), path("", include("plane.web.urls")), ] diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 75437fbee..2da24092a 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries): duration=int(digit), subsequent=date_query[1], term=term, - date_filter="created_at__date", + date_filter=date_term, offset=date_query[2], ) else: diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 544ed8fef..793614cc0 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -28,15 +28,15 @@ class Cursor: @classmethod def from_string(cls, value): - bits = value.split(":") - if len(bits) != 3: - raise ValueError try: + bits = value.split(":") + if len(bits) != 3: + raise ValueError("Cursor must be in the format 'value:offset:is_prev'") + 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) + return cls(value, int(bits[1]), bool(int(bits[2]))) + except (TypeError, ValueError) as e: + raise ValueError(f"Invalid cursor format: {e}") class CursorResult(Sequence): @@ -125,7 +125,8 @@ class OffsetPaginator: if self.on_results: results = self.on_results(results) - max_hits = math.ceil(queryset.count() / limit) + count = queryset.count() + max_hits = math.ceil(count / limit) return CursorResult( results=results, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 249b29d48..6832297e9 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,14 +1,9 @@ # base requirements -Django==4.2.5 -django-braces==1.15.0 -django-taggit==4.0.0 -psycopg==3.1.10 -django-oauth-toolkit==2.3.0 -mistune==3.0.1 +Django==4.2.7 +psycopg==3.1.12 djangorestframework==3.14.0 redis==4.6.0 -django-nested-admin==4.0.2 django-cors-headers==4.2.0 whitenoise==6.5.0 django-allauth==0.55.2 @@ -17,21 +12,25 @@ django-filter==23.2 jsonmodels==2.6.0 djangorestframework-simplejwt==5.3.0 sentry-sdk==1.30.0 -django-s3-storage==0.14.0 +django-storages==1.14 django-crum==0.7.9 -django-guardian==2.4.0 -dj_rest_auth==2.2.5 google-auth==2.22.0 google-api-python-client==2.97.0 django-redis==5.3.0 uvicorn==0.23.2 channels==4.0.0 -openai==0.28.0 +openai==1.2.4 slack-sdk==3.21.3 celery==5.3.4 django_celery_beat==2.5.0 -psycopg-binary==3.1.10 -psycopg-c==3.1.10 +psycopg-binary==3.1.12 +psycopg-c==3.1.12 scout-apm==2.26.1 openpyxl==3.1.2 -beautifulsoup4==4.12.2 \ No newline at end of file +beautifulsoup4==4.12.2 +dj-database-url==2.1.0 +posthog==3.0.2 +cryptography==41.0.5 +lxml==4.9.3 +boto3==1.28.40 + diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index 5e3483a96..a0e9f8a17 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,11 +1,3 @@ -r base.txt -dj-database-url==2.1.0 gunicorn==21.2.0 -whitenoise==6.5.0 -django-storages==1.14 -boto3==1.28.40 -django-anymail==10.1 -django-debug-toolbar==4.1.0 -gevent==23.7.0 -psycogreen==1.0.2 \ No newline at end of file diff --git a/apiserver/templates/emails/auth/email_verification.html b/apiserver/templates/emails/auth/email_verification.html deleted file mode 100644 index ea642bbd8..000000000 --- a/apiserver/templates/emails/auth/email_verification.html +++ /dev/null @@ -1,11 +0,0 @@ - - -

- 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 index 76b8903d7..a58a8cef7 100644 --- a/apiserver/templates/emails/auth/forgot_password.html +++ b/apiserver/templates/emails/auth/forgot_password.html @@ -1,21 +1,1665 @@ - - - - -

- Dear {{first_name}},

- We received a request to reset your password for your Plane account. -

- To proceed with resetting your password, please click on the link below: -
- {{forgot_password_url}} -

- If you didn't request to reset your password, please ignore this email. Your account will remain secure. -

- If you have any questions or need further assistance, please contact our support team. -

- Thank you for using Plane. -

- - - \ No newline at end of file + + + + + + + + Set a new password to your Plane account + + + + + + + + + + + + + + + diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index 63fbe5e32..ba469db7e 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -1,367 +1,1488 @@ - - - - - - - Login for Plane - - - - - - - - - - - - - - + + + + + + diff --git a/apiserver/templates/emails/exports/issues.html b/apiserver/templates/emails/exports/issues.html deleted file mode 100644 index a97432b9b..000000000 --- a/apiserver/templates/emails/exports/issues.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Dear {{username}},
- Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.
- Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at engineering@plane.so. We're here to help!
- Thank you for using Plane. We hope this export will aid you in effectively managing your projects.
- Regards, - Team Plane - diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html index ea2f1cdcf..630a5eab3 100644 --- a/apiserver/templates/emails/invitations/project_invitation.html +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -5,7 +5,7 @@ - {{ Inviter }} invited you to join {{ Workspace-Name }} on Plane + {{ first_name }} invited you to join {{ project_name }} on Plane diff --git a/apiserver/templates/emails/invitations/workspace_invitation.html b/apiserver/templates/emails/invitations/workspace_invitation.html index 2384aa18d..cdca6d62d 100644 --- a/apiserver/templates/emails/invitations/workspace_invitation.html +++ b/apiserver/templates/emails/invitations/workspace_invitation.html @@ -1,349 +1,1654 @@ - - - - - - - {{first_name}} invited you to join {{workspace_name}} on Plane - - - - - - - - - - - - - - + + + + + + diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md new file mode 100644 index 000000000..9bd11568f --- /dev/null +++ b/deploy/coolify/README.md @@ -0,0 +1,8 @@ +## Coolify Setup + +Access the `coolify-docker-compose` file [here](https://raw.githubusercontent.com/makeplane/plane/master/deploy/coolify/coolify-docker-compose.yml) or download using using below command + +``` +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/coolify/coolify-docker-compose.yml + +``` \ No newline at end of file diff --git a/deploy/coolify/coolify-docker-compose.yml b/deploy/coolify/coolify-docker-compose.yml new file mode 100644 index 000000000..2a21c61a8 --- /dev/null +++ b/deploy/coolify/coolify-docker-compose.yml @@ -0,0 +1,232 @@ + +services: + web: + container_name: web + platform: linux/amd64 + image: makeplane/plane-frontend:latest + restart: always + command: /usr/local/bin/start.sh web/server.js web + environment: + - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} + - NEXT_PUBLIC_DEPLOY_URL=$SERVICE_FQDN_SPACE_8082 + depends_on: + - api + - worker + + space: + container_name: space + platform: linux/amd64 + image: makeplane/plane-space:latest + restart: always + command: /usr/local/bin/start.sh space/server.js space + environment: + - SERVICE_FQDN_SPACE_8082=/api + - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} + depends_on: + - api + - worker + - web + + api: + container_name: api + platform: linux/amd64 + image: makeplane/plane-backend:latest + restart: always + command: ./bin/takeoff + environment: + - DEBUG=${DEBUG:-0} + - SENTRY_DSN=${SENTRY_DSN:-""} + - PGUSER=${PGUSER:-plane} + - PGPASSWORD=${PGPASSWORD:-plane} + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + - REDIS_HOST=${REDIS_HOST:-plane-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_URL=redis://${REDIS_HOST}:6379/ + - EMAIL_HOST=${EMAIL_HOST:-""} + - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} + - EMAIL_PORT=${EMAIL_PORT:-587} + - EMAIL_FROM=${EMAIL_FROM:-Team Plane } + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-sk-} + - GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} + - DOCKERIZED=${DOCKERIZED:-1} + - USE_MINIO=${USE_MINIO:-1} + - NGINX_PORT=${NGINX_PORT:-8082} + - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} + - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} + - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} + - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} + - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + - WEB_URL=$SERVICE_FQDN_PLANE_8082 + depends_on: + - plane-db + - plane-redis + + worker: + container_name: bgworker + platform: linux/amd64 + image: makeplane/plane-backend:latest + restart: always + command: ./bin/worker + environment: + - DEBUG=${DEBUG:-0} + - SENTRY_DSN=${SENTRY_DSN:-""} + - PGUSER=${PGUSER:-plane} + - PGPASSWORD=${PGPASSWORD:-plane} + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + - REDIS_HOST=${REDIS_HOST:-plane-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_URL=redis://${REDIS_HOST}:6379/ + - EMAIL_HOST=${EMAIL_HOST:-""} + - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} + - EMAIL_PORT=${EMAIL_PORT:-587} + - EMAIL_FROM=${EMAIL_FROM:-Team Plane } + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-sk-} + - GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} + - DOCKERIZED=${DOCKERIZED:-1} + - USE_MINIO=${USE_MINIO:-1} + - NGINX_PORT=${NGINX_PORT:-8082} + - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} + - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} + - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + depends_on: + - api + - plane-db + - plane-redis + + beat-worker: + container_name: beatworker + platform: linux/amd64 + image: makeplane/plane-backend:latest + restart: always + command: ./bin/beat + environment: + - DEBUG=${DEBUG:-0} + - SENTRY_DSN=${SENTRY_DSN:-""} + - PGUSER=${PGUSER:-plane} + - PGPASSWORD=${PGPASSWORD:-plane} + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + - REDIS_HOST=${REDIS_HOST:-plane-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_URL=redis://${REDIS_HOST}:6379/ + - EMAIL_HOST=${EMAIL_HOST:-""} + - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} + - EMAIL_PORT=${EMAIL_PORT:-587} + - EMAIL_FROM=${EMAIL_FROM:-Team Plane } + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} + - OPENAI_API_KEY=${OPENAI_API_KEY:-sk-} + - GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} + - DOCKERIZED=${DOCKERIZED:-1} + - USE_MINIO=${USE_MINIO:-1} + - NGINX_PORT=${NGINX_PORT:-8082} + - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} + - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} + - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + depends_on: + - api + - plane-db + - plane-redis + + plane-db: + container_name: plane-db + image: postgres:15.2-alpine + restart: always + command: postgres -c 'max_connections=1000' + volumes: + - pgdata:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${POSTGRES_USER:-plane} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} + - POSTGRES_DB=${POSTGRES_DB:-plane} + - PGDATA=${PGDATA:-/var/lib/postgresql/data} + + plane-redis: + container_name: plane-redis + image: redis:6.2.7-alpine + restart: always + volumes: + - redisdata:/data + + plane-minio: + container_name: plane-minio + image: minio/minio + restart: always + command: server /export --console-address ":9090" + volumes: + - uploads:/export + environment: + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-access-key} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-secret-key} + + createbuckets: + image: minio/mc + entrypoint: > + /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " + environment: + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + depends_on: + - plane-minio + + # Comment this if you already have a reverse proxy running + proxy: + container_name: proxy + platform: linux/amd64 + image: makeplane/plane-proxy:latest + ports: + - 8082:80 + environment: + - SERVICE_FQDN_PLANE_8082 + - NGINX_PORT=${NGINX_PORT:-8082} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + depends_on: + - web + - api + - space + +volumes: + pgdata: + redisdata: + uploads: diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md new file mode 100644 index 000000000..fe7991fcf --- /dev/null +++ b/deploy/kubernetes/README.md @@ -0,0 +1,8 @@ + +# Helm Chart + +Click on the below link to access the helm chart instructions. + +[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/makeplane)](https://artifacthub.io/packages/search?repo=makeplane) + + diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md new file mode 100644 index 000000000..e36c33c76 --- /dev/null +++ b/deploy/selfhost/README.md @@ -0,0 +1,309 @@ +# Self Hosting + +In this guide, we will walk you through the process of setting up a self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization. + +We will cover two main options for setting up your self-hosted environment: using a cloud server or using your desktop. For the cloud server, we will use an AWS EC2 instance. For the desktop, we will use Docker to create a local environment. + +Let's get started! + +## Setting up Docker Environment +
+ Option 1 - Using Cloud Server +

Best way to start is to create EC2 maching on AWS. It must of minimum t3.medium/t3a/medium

+

Run the below command to install docker engine.

+ + ```curl -fsSL https://get.docker.com -o install-docker.sh``` +
+ +--- + +
+ Option 2 - Using Desktop + + #### For Mac +
    +
  1. Download Docker Desktop for Mac from the Docker Hub.
  2. +
  3. Double-click the downloaded `.dmg` file and drag the Docker app icon to the Applications folder.
  4. +
  5. Open Docker Desktop from the Applications folder. You might be asked to provide your system password to install additional software.
  6. +
+ + #### For Windows: +
    +
  1. Download Docker Desktop for Windows from the Docker Hub.
  2. +
  3. Run the installer and follow the instructions. You might be asked to enable Hyper-V and "Containers" Windows features.
  4. +
  5. Open Docker Desktop. You might be asked to log out and log back in, or restart your machine, for changes to take effect.
  6. +
+ + After installation, you can verify the installation by opening a terminal (Command Prompt on Windows, Terminal app on Mac) and running the command `docker --version`. This should display the installed version of Docker. +
+ +--- + +## Installing Plane + +Installing plane is a very easy and minimal step process. + +### Prerequisite +- Docker installed and running +- OS with bash scripting enabled (Ubuntu, Linux AMI, macos). Windows systems need to have [gitbash](https://git-scm.com/download/win) +- User context used must have access to docker services. In most cases, use sudo su to switch as root user +- Use the terminal (or gitbash) window to run all the future steps + +### Downloading Latest Stable Release + +``` +mkdir plane-selfhost + +cd plane-selfhost + +curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh + +chmod +x setup.sh +``` + +
+ Downloading Preview Release + +``` +mkdir plane-selfhost + +cd plane-selfhost + +export RELEASE=preview + +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/install.sh | sed 's@BRANCH=master@BRANCH='"$RELEASE"'@' > setup.sh + +chmod +x setup.sh +``` + +
+ +--- + +### Proceed with setup + +Above steps will set you ready to install and start plane services. + +Lets get started by running the `./setup.sh` command. + +This will prompt you with the below options. + +``` +Select a Action you want to perform: + 1) Install + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) Exit + +Action [2]: 1 +``` + +For the 1st time setup, type "1" as action input. + +This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that +- `docker-compose.yaml` +- `.env` + +Again the `options [1-6]` will be popped up and this time hit `6` to exit. + +--- + +### Continue with setup - Environment Settings + +Before proceeding, we suggest used to review `.env` file and set the values. +Below are the most import keys you must refer to. *You can use any text editor to edit this file*. + +> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`) + +> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`) + +> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`) + +There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform. + +--- + +### Continue with setup - Start Server + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices + +``` +Select a Action you want to perform: + 1) Install + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) Exit + +Action [2]: 2 +``` + +Expect something like this. +![Downloading docker images](images/download.png) + +Be patient as it might take sometime based on download speed and system configuration. If all goes well, you must see something like this + +![Downloading completed](images/started.png) + +This is the confirmation that all images were downloaded and the services are up & running. + +You have successfully self hosted `Plane` instance. Access the application by going to IP or domain you have configured it (e.g `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`) + +--- + +### Stopping the Server + +In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that. + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices + +``` +Select a Action you want to perform: + 1) Install + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) Exit + +Action [2]: 3 +``` + +If all goes well, you must see something like this + +![Stop Services](images/stopped.png) + +--- + +### Restarting the Server + +In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option. + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices + +``` +Select a Action you want to perform: + 1) Install + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) Exit + +Action [2]: 4 +``` + +If all goes well, you must see something like this + +![Restart Services](images/restart.png) + +--- + +### Upgrading Plane Version + +It is always advised to keep Plane up to date with the latest release. + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release. + +``` +Select a Action you want to perform: + 1) Install + 2) Start + 3) Stop + 4) Restart + 5) Upgrade + 6) Exit + +Action [2]: 5 +``` + +By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `variables-upgrade.env`. Here system will not replace `.env` with the new one. + +You must expect the below message + +![Alt text](images/upgrade.png) + +Once done, choose `6` to exit from prompt. + +> It is very important for you to compare the 2 files `variables-upgrade.env` and `.env`. Copy the newly added variable from downloaded file to `.env` and set the expected values. + +Once done with making changes in `.env` file, jump on to `Start Server` + + +## Upgrading from v0.13.2 to v0.14.x + +This is one time activity for users who are upgrading from v0.13.2 to v0.14.0 + +As there has been significant changes to Self Hosting process, this step mainly covers the data migration from current (v0.13.2) docker volumes from newly created volumes + +> Before we begin with migration, make sure your v0.14.0 was started and then stopped. This is required to know the newly created docker volume names. + +Begin with downloading the migration script using below command + +``` + +curl -fsSL -o migrate.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/migration-0.13-0.14.sh + +chmod +x migrate.sh + +``` + +Now run the `./migrate.sh` command and expect the instructions as below + +``` +****************************************************************** + +This script is solely for the migration purpose only. +This is a 1 time migration of volume data from v0.13.2 => v0.14.x + +Assumption: +1. Postgres data volume name ends with _pgdata +2. Minio data volume name ends with _uploads +3. Redis data volume name ends with _redisdata + +Any changes to this script can break the migration. + +Before you proceed, make sure you run the below command +to know the docker volumes + +docker volume ls -q | grep -i "_pgdata" +docker volume ls -q | grep -i "_uploads" +docker volume ls -q | grep -i "_redisdata" + +******************************************************* + +Given below list of REDIS volumes, identify the prefix of source and destination volumes leaving "_redisdata" +--------------------- +plane-app_redisdata +v0132_redisdata + +Provide the Source Volume Prefix : +``` + +**Open another terminal window**, and run the mentioned 3 command. This may be different for users who have changed the volume names in their previous setup (v0.13.2) + +For every command you must see 2 records something like shown in above example of `redisdata` + +To move forward, you would need PREFIX of old setup and new setup. As per above example, `v0132` is the prefix of v0.13.2 and `plane-app` is the prefix of v0.14.0 setup + +**Back to original terminal window**, *Provide the Source Volume Prefix* and hit ENTER. + +Now you will be prompted to *Provide Destination Volume Prefix*. Provide the value and hit ENTER + +``` +Provide the Source Volume Prefix : v0132 +Provide the Destination Volume Prefix : plane-app +``` + +In case the suffixes are wrong or the mentioned volumes are not found, you will receive the error shown below. The image below displays an error for source volumes. + +![Migrate Error](images/migrate-error.png) + +In case of successful migration, it will be a silent exit without error. + +Now its time to restart v0.14.0 setup. + + diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index c571291cf..ba0c28827 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -2,15 +2,20 @@ version: "3.8" x-app-env : &app-env environment: - - NGINX_PORT=${NGINX_PORT:-84} + - NGINX_PORT=${NGINX_PORT:-80} + - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} - - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated + - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} # deprecated + - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated - SENTRY_DSN=${SENTRY_DSN:-""} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - - DOCKERIZED=${DOCKERIZED:-1} - # Gunicorn Workers + - DOCKERIZED=${DOCKERIZED:-1} # deprecated + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} + # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS - PGHOST=${PGHOST:-plane-db} @@ -24,24 +29,25 @@ x-app-env : &app-env - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} - # EMAIL SETTINGS + # EMAIL SETTINGS - Deprecated can be configured through admin panel - EMAIL_HOST=${EMAIL_HOST:-""} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"} + - EMAIL_FROM=${EMAIL_FROM:-"Team Plane "} - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # OPENAI SETTINGS + # OPENAI SETTINGS - Deprecated can be configured through admin panel - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - # LOGIN/SIGNUP SETTINGS + # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} + # Application secret - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS - USE_MINIO=${USE_MINIO:-1} @@ -55,6 +61,8 @@ x-app-env : &app-env - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + + services: web: <<: *app-env @@ -95,7 +103,6 @@ services: worker: <<: *app-env - container_name: bgworker platform: linux/amd64 image: makeplane/plane-backend:${APP_RELEASE:-latest} restart: unless-stopped @@ -107,7 +114,6 @@ services: beat-worker: <<: *app-env - container_name: beatworker platform: linux/amd64 image: makeplane/plane-backend:${APP_RELEASE:-latest} restart: unless-stopped @@ -119,7 +125,6 @@ services: plane-db: <<: *app-env - container_name: plane-db image: postgres:15.2-alpine restart: unless-stopped command: postgres -c 'max_connections=1000' @@ -128,7 +133,6 @@ services: plane-redis: <<: *app-env - container_name: plane-redis image: redis:6.2.7-alpine restart: unless-stopped volumes: @@ -136,25 +140,15 @@ services: plane-minio: <<: *app-env - container_name: plane-minio image: minio/minio restart: unless-stopped command: server /export --console-address ":9090" volumes: - uploads:/export - createbuckets: - <<: *app-env - image: minio/mc - entrypoint: > - /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " - depends_on: - - plane-minio - # Comment this if you already have a reverse proxy running proxy: <<: *app-env - container_name: proxy platform: linux/amd64 image: makeplane/plane-proxy:${APP_RELEASE:-latest} ports: diff --git a/deploy/selfhost/images/download.png b/deploy/selfhost/images/download.png new file mode 100644 index 000000000..bb0d1183e Binary files /dev/null and b/deploy/selfhost/images/download.png differ diff --git a/deploy/selfhost/images/migrate-error.png b/deploy/selfhost/images/migrate-error.png new file mode 100644 index 000000000..f42ec441a Binary files /dev/null and b/deploy/selfhost/images/migrate-error.png differ diff --git a/deploy/selfhost/images/restart.png b/deploy/selfhost/images/restart.png new file mode 100644 index 000000000..0387599a0 Binary files /dev/null and b/deploy/selfhost/images/restart.png differ diff --git a/deploy/selfhost/images/started.png b/deploy/selfhost/images/started.png new file mode 100644 index 000000000..d6a0a0baa Binary files /dev/null and b/deploy/selfhost/images/started.png differ diff --git a/deploy/selfhost/images/stopped.png b/deploy/selfhost/images/stopped.png new file mode 100644 index 000000000..0f5876882 Binary files /dev/null and b/deploy/selfhost/images/stopped.png differ diff --git a/deploy/selfhost/images/upgrade.png b/deploy/selfhost/images/upgrade.png new file mode 100644 index 000000000..b78fbbb60 Binary files /dev/null and b/deploy/selfhost/images/upgrade.png differ diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index f9437a844..645e99cb8 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -1,9 +1,8 @@ #!/bin/bash -BRANCH=${BRANCH:-master} +BRANCH=master SCRIPT_DIR=$PWD PLANE_INSTALL_DIR=$PWD/plane-app -mkdir -p $PLANE_INSTALL_DIR/archive function install(){ echo @@ -28,7 +27,15 @@ function download(){ mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env fi + if [ "$BRANCH" != "master" ]; + then + cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml + sed -e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \ + $PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml + rm $PLANE_INSTALL_DIR/temp.yaml + fi + echo "" echo "Latest version is now available for you to use" echo "" @@ -108,4 +115,10 @@ function askForAction(){ fi } +if [ "$BRANCH" != "master" ]; +then + PLANE_INSTALL_DIR=$PWD/plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g') +fi +mkdir -p $PLANE_INSTALL_DIR/archive + askForAction diff --git a/deploy/selfhost/migration-0.13-0.14.sh b/deploy/selfhost/migration-0.13-0.14.sh new file mode 100755 index 000000000..d03f87780 --- /dev/null +++ b/deploy/selfhost/migration-0.13-0.14.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +echo ' +****************************************************************** + +This script is solely for the migration purpose only. +This is a 1 time migration of volume data from v0.13.2 => v0.14.x + +Assumption: +1. Postgres data volume name ends with _pgdata +2. Minio data volume name ends with _uploads +3. Redis data volume name ends with _redisdata + +Any changes to this script can break the migration. + +Before you proceed, make sure you run the below command +to know the docker volumes + +docker volume ls -q | grep -i "_pgdata" +docker volume ls -q | grep -i "_uploads" +docker volume ls -q | grep -i "_redisdata" + +******************************************************* +' + +DOWNLOAD_FOL=./download +rm -rf ${DOWNLOAD_FOL} +mkdir -p ${DOWNLOAD_FOL} + +function volumeExists { + if [ "$(docker volume ls -f name=$1 | awk '{print $NF}' | grep -E '^'$1'$')" ]; then + return 0 + else + return 1 + fi +} + +function readPrefixes(){ + echo '' + echo 'Given below list of REDIS volumes, identify the prefix of source and destination volumes leaving "_redisdata" ' + echo '---------------------' + docker volume ls -q | grep -i "_redisdata" + echo '' + + read -p "Provide the Source Volume Prefix : " SRC_VOL_PREFIX + until [ "$SRC_VOL_PREFIX" ]; do + read -p "Provide the Source Volume Prefix : " SRC_VOL_PREFIX + done + + read -p "Provide the Destination Volume Prefix : " DEST_VOL_PREFIX + until [ "$DEST_VOL_PREFIX" ]; do + read -p "Provide the Source Volume Prefix : " DEST_VOL_PREFIX + done + + echo '' + echo 'Prefix Provided ' + echo " Source : ${SRC_VOL_PREFIX}" + echo " Destination : ${DEST_VOL_PREFIX}" + echo '---------------------------------------' +} + +function migrate(){ + + SRC_VOLUME=${SRC_VOL_PREFIX}_${VOL_NAME_SUFFIX} + DEST_VOLUME=${DEST_VOL_PREFIX}_${VOL_NAME_SUFFIX} + + if volumeExists $SRC_VOLUME; then + if volumeExists $DEST_VOLUME; then + GOOD_TO_GO=1 + else + echo "Destination Volume '$DEST_VOLUME' does not exist" + echo '' + fi + else + echo "Source Volume '$SRC_VOLUME' does not exist" + echo '' + fi + + if [ $GOOD_TO_GO = 1 ]; then + + echo "MIGRATING ${VOL_NAME_SUFFIX} FROM ${SRC_VOLUME} => ${DEST_VOLUME}" + + TEMP_CONTAINER=$(docker run -d -v $SRC_VOLUME:$CONTAINER_VOL_FOLDER busybox true) + docker cp -q $TEMP_CONTAINER:$CONTAINER_VOL_FOLDER ${DOWNLOAD_FOL}/${VOL_NAME_SUFFIX} + docker rm $TEMP_CONTAINER &> /dev/null + + TEMP_CONTAINER=$(docker run -d -v $DEST_VOLUME:$CONTAINER_VOL_FOLDER busybox true) + if [ "$VOL_NAME_SUFFIX" = "pgdata" ]; then + docker cp -q ${DOWNLOAD_FOL}/${VOL_NAME_SUFFIX} $TEMP_CONTAINER:$CONTAINER_VOL_FOLDER/_temp + docker run --rm -v $DEST_VOLUME:$CONTAINER_VOL_FOLDER \ + -e DATA_FOLDER="${CONTAINER_VOL_FOLDER}" \ + busybox /bin/sh -c 'cp -Rf $DATA_FOLDER/_temp/* $DATA_FOLDER ' + else + docker cp -q ${DOWNLOAD_FOL}/${VOL_NAME_SUFFIX} $TEMP_CONTAINER:$CONTAINER_VOL_FOLDER + fi + docker rm $TEMP_CONTAINER &> /dev/null + + echo '' + fi +} + +readPrefixes + +# MIGRATE DB +CONTAINER_VOL_FOLDER=/var/lib/postgresql/data +VOL_NAME_SUFFIX=pgdata +migrate + +# MIGRATE REDIS +CONTAINER_VOL_FOLDER=/data +VOL_NAME_SUFFIX=redisdata +migrate + +# MIGRATE MINIO +CONTAINER_VOL_FOLDER=/export +VOL_NAME_SUFFIX=uploads +migrate + diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index b12031126..6be9ca2f4 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -5,13 +5,17 @@ SPACE_REPLICAS=1 API_REPLICAS=1 NGINX_PORT=80 +WEB_URL=http://localhost DEBUG=0 -DJANGO_SETTINGS_MODULE=plane.settings.selfhosted NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN="" +SENTRY_ENVIRONMENT="production" +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" -DOCKERIZED=1 +DOCKERIZED=1 # deprecated +CORS_ALLOWED_ORIGINS="http://localhost" #DB SETTINGS PGHOST=plane-db @@ -32,16 +36,14 @@ EMAIL_HOST="" EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" EMAIL_PORT=587 -EMAIL_FROM="Team Plane <team@mailer.plane.so>" +EMAIL_FROM="Team Plane " EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 -DEFAULT_EMAIL=captain@plane.so -DEFAULT_PASSWORD=password123 # OPENAI SETTINGS -OPENAI_API_BASE=https://api.openai.com/v1 -OPENAI_API_KEY="sk-" -GPT_ENGINE="gpt-3.5-turbo" +OPENAI_API_BASE=https://api.openai.com/v1 # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # LOGIN/SIGNUP SETTINGS ENABLE_SIGNUP=1 diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 4f433e3ac..58cab3776 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -35,17 +35,6 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - createbuckets: - image: minio/mc - networks: - - dev_env - entrypoint: > - /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " - env_file: - - .env - depends_on: - - plane-minio - plane-db: container_name: plane-db image: postgres:15.2-alpine diff --git a/docker-compose.yml b/docker-compose.yml index 0895aa1ae..e39f0d8d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,15 +108,6 @@ services: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - createbuckets: - image: minio/mc - entrypoint: > - /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " - env_file: - - .env - depends_on: - - plane-minio - # Comment this if you already have a reverse proxy running proxy: container_name: proxy diff --git a/package.json b/package.json index 86f010f3f..ad3156a86 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "turbo": "^1.10.16" }, "resolutions": { - "@types/react": "18.2.0" + "@types/react": "18.2.42" }, "packageManager": "yarn@1.22.19" } diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ab6c77724..e9f857d8a 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -17,22 +17,25 @@ } }, "scripts": { - "build": "tsup", + "build": "tsup --minify", "dev": "tsup --watch", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "peerDependencies": { "next": "12.3.2", - "next-themes": "^0.2.1", "react": "^18.2.0", "react-dom": "18.2.0" }, "dependencies": { - "@blueprintjs/popover2": "^2.0.10", + "@plane/editor-types": "*", "@tiptap/core": "^2.1.7", + "@tiptap/extension-blockquote": "^2.1.13", + "@tiptap/extension-code-block-lowlight": "^2.1.12", "@tiptap/extension-color": "^2.1.11", "@tiptap/extension-image": "^2.1.7", "@tiptap/extension-link": "^2.1.7", + "@tiptap/extension-list-item": "^2.1.12", "@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-table": "^2.1.6", "@tiptap/extension-table-cell": "^2.1.6", @@ -42,30 +45,28 @@ "@tiptap/extension-task-list": "^2.1.7", "@tiptap/extension-text-style": "^2.1.11", "@tiptap/extension-underline": "^2.1.7", - "@tiptap/prosemirror-tables": "^1.1.4", - "jsx-dom-cjs": "^8.0.3", "@tiptap/pm": "^2.1.7", + "@tiptap/prosemirror-tables": "^1.1.4", "@tiptap/react": "^2.1.7", "@tiptap/starter-kit": "^2.1.10", "@tiptap/suggestion": "^2.0.4", - "@types/node": "18.15.3", - "@types/react": "^18.2.5", - "@types/react-dom": "18.0.11", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", - "eslint": "8.36.0", - "eslint-config-next": "13.2.4", - "eventsource-parser": "^0.1.0", + "highlight.js": "^11.8.0", + "jsx-dom-cjs": "^8.0.3", + "lowlight": "^3.0.0", "lucide-react": "^0.244.0", - "react-markdown": "^8.0.7", "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.2", - "use-debounce": "^9.0.4" + "tiptap-markdown": "^0.8.2" }, "devDependencies": { + "@types/node": "18.15.3", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", "eslint": "^7.32.0", + "eslint-config-next": "13.2.4", "postcss": "^8.4.29", "tailwind-config-custom": "*", "tsconfig": "*", @@ -79,4 +80,4 @@ "nextjs", "react" ] -} +} \ No newline at end of file diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 9c1c292b2..bdf533193 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -1,6 +1,7 @@ // styles // import "./styles/tailwind.css"; // import "./styles/editor.css"; +import "./styles/github-dark.css"; export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection"; @@ -14,8 +15,8 @@ export { EditorContainer } from "./ui/components/editor-container"; export { EditorContentWrapper } from "./ui/components/editor-content"; // hooks -export { useEditor } from "./ui/hooks/useEditor"; -export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor"; +export { useEditor } from "./ui/hooks/use-editor"; +export { useReadOnlyEditor } from "./ui/hooks/use-read-only-editor"; // helper items export * from "./ui/menus/menu-items"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 8f9e36350..725b72b8b 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -1,6 +1,7 @@ +import { UploadImage } from "@plane/editor-types"; import { Editor, Range } from "@tiptap/core"; -import { UploadImage } from "../types/upload-image"; import { startImageUpload } from "../ui/plugins/upload-image"; +import { findTableAncestor } from "./utils"; export const toggleHeadingOne = (editor: Editor, range?: Range) => { if (range) @@ -50,10 +51,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleUnderline().run(); }; -export const toggleCode = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleCode().run(); - else editor.chain().focus().toggleCode().run(); +export const toggleCodeBlock = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + else editor.chain().focus().toggleCodeBlock().run(); }; + export const toggleOrderedList = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); @@ -94,6 +96,15 @@ export const toggleBlockquote = (editor: Editor, range?: Range) => { }; export const insertTableCommand = (editor: Editor, range?: Range) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } if (range) editor .chain() diff --git a/packages/editor/core/src/styles/github-dark.css b/packages/editor/core/src/styles/github-dark.css new file mode 100644 index 000000000..9374de403 --- /dev/null +++ b/packages/editor/core/src/styles/github-dark.css @@ -0,0 +1,85 @@ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em; +} +code.hljs { + padding: 3px 5px; +} +.hljs { + color: #c9d1d9; + background: #0d1117; +} +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + color: #ff7b72; +} +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + color: #d2a8ff; +} +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id, +.hljs-variable { + color: #79c0ff; +} +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: #a5d6ff; +} +.hljs-built_in, +.hljs-symbol { + color: #ffa657; +} +.hljs-code, +.hljs-comment, +.hljs-formula { + color: #8b949e; +} +.hljs-name, +.hljs-quote, +.hljs-selector-pseudo, +.hljs-selector-tag { + color: #7ee787; +} +.hljs-subst { + color: #c9d1d9; +} +.hljs-section { + color: #1f6feb; + font-weight: 700; +} +.hljs-bullet { + color: #f2cc60; +} +.hljs-emphasis { + color: #c9d1d9; + font-style: italic; +} +.hljs-strong { + color: #c9d1d9; + font-weight: 700; +} +.hljs-addition { + color: #aff5b4; + background-color: #033a16; +} +.hljs-deletion { + color: #ffdcd7; + background-color: #67060c; +} diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/core/src/ui/extensions/code/index.tsx new file mode 100644 index 000000000..016cec2c3 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/index.tsx @@ -0,0 +1,29 @@ +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; + +import { common, createLowlight } from "lowlight"; +import ts from "highlight.js/lib/languages/typescript"; + +const lowlight = createLowlight(common); +lowlight.register("ts", ts); + +export const CustomCodeBlock = CodeBlockLowlight.extend({ + addKeyboardShortcuts() { + return { + Tab: ({ editor }) => { + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + return editor.commands.insertContent(" "); + }, + }; + }, +}).configure({ + lowlight, + defaultLanguage: "plaintext", + exitOnTripleEnter: false, +}); diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts new file mode 100644 index 000000000..b91209e92 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts @@ -0,0 +1 @@ +export * from "./list-keymap"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts new file mode 100644 index 000000000..5312cb20e --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts @@ -0,0 +1,33 @@ +import { getNodeType } from "@tiptap/core"; +import { NodeType } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; + +export const findListItemPos = ( + typeOrName: string | NodeType, + state: EditorState, +) => { + const { $from } = state.selection; + const nodeType = getNodeType(typeOrName, state.schema); + + let currentNode = null; + let currentDepth = $from.depth; + let currentPos = $from.pos; + let targetDepth: number | null = null; + + while (currentDepth > 0 && targetDepth === null) { + currentNode = $from.node(currentDepth); + + if (currentNode.type === nodeType) { + targetDepth = currentDepth; + } else { + currentDepth -= 1; + currentPos -= 1; + } + } + + if (targetDepth === null) { + return null; + } + + return { $pos: state.doc.resolve(currentPos), depth: targetDepth }; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts new file mode 100644 index 000000000..e81b19592 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts @@ -0,0 +1,20 @@ +import { getNodeAtPosition } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; + +export const getNextListDepth = (typeOrName: string, state: EditorState) => { + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos) { + return false; + } + + const [, depth] = getNodeAtPosition( + state, + typeOrName, + listItemPos.$pos.pos + 4, + ); + + return depth; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts new file mode 100644 index 000000000..1eac3ae4a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts @@ -0,0 +1,78 @@ +import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; + +import { findListItemPos } from "./find-list-item-pos"; +import { hasListBefore } from "./has-list-before"; + +export const handleBackspace = ( + editor: Editor, + name: string, + parentListTypes: string[], +) => { + // this is required to still handle the undo handling + if (editor.commands.undoInputRule()) { + return true; + } + + // if the cursor is not at the start of a node + // do nothing and proceed + if (!isAtStartOfNode(editor.state)) { + return false; + } + + // if the current item is NOT inside a list item & + // the previous item is a list (orderedList or bulletList) + // move the cursor into the list and delete the current item + if ( + !isNodeActive(editor.state, name) && + hasListBefore(editor.state, name, parentListTypes) + ) { + const { $anchor } = editor.state.selection; + + const $listPos = editor.state.doc.resolve($anchor.before() - 1); + + const listDescendants: Array<{ node: Node; pos: number }> = []; + + $listPos.node().descendants((node, pos) => { + if (node.type.name === name) { + listDescendants.push({ node, pos }); + } + }); + + const lastItem = listDescendants.at(-1); + + if (!lastItem) { + return false; + } + + const $lastItemPos = editor.state.doc.resolve( + $listPos.start() + lastItem.pos + 1, + ); + + return editor + .chain() + .cut( + { from: $anchor.start() - 1, to: $anchor.end() + 1 }, + $lastItemPos.end(), + ) + .joinForward() + .run(); + } + + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + const listItemPos = findListItemPos(name, editor.state); + + if (!listItemPos) { + return false; + } + + // if current node is a list item and cursor it at start of a list node, + // simply lift the list item i.e. remove it as a list item (task/bullet/ordered) + // irrespective of above node being a list or not + return editor.chain().liftListItem(name).run(); +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts new file mode 100644 index 000000000..5f47baf9d --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts @@ -0,0 +1,34 @@ +import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core"; + +import { nextListIsDeeper } from "./next-list-is-deeper"; +import { nextListIsHigher } from "./next-list-is-higher"; + +export const handleDelete = (editor: Editor, name: string) => { + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + // if the cursor is not at the end of a node + // do nothing and proceed + if (!isAtEndOfNode(editor.state, name)) { + return false; + } + + // check if the next node is a list with a deeper depth + if (nextListIsDeeper(name, editor.state)) { + return editor + .chain() + .focus(editor.state.selection.from + 4) + .lift(name) + .joinBackward() + .run(); + } + + if (nextListIsHigher(name, editor.state)) { + return editor.chain().joinForward().joinBackward().run(); + } + + return editor.commands.joinItemForward(); +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts new file mode 100644 index 000000000..99c8ac18b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListBefore = ( + editorState: EditorState, + name: string, + parentListTypes: string[], +) => { + const { $anchor } = editorState.selection; + + const previousNodePos = Math.max(0, $anchor.pos - 2); + + const previousNode = editorState.doc.resolve(previousNodePos).node(); + + if (!previousNode || !parentListTypes.includes(previousNode.type.name)) { + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts new file mode 100644 index 000000000..da20516e1 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts @@ -0,0 +1,20 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListItemAfter = ( + typeOrName: string, + state: EditorState, +): boolean => { + const { $anchor } = state.selection; + + const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2); + + if ($targetPos.index() === $targetPos.parent.childCount - 1) { + return false; + } + + if ($targetPos.nodeAfter?.type.name !== typeOrName) { + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts new file mode 100644 index 000000000..4cb1236ab --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts @@ -0,0 +1,20 @@ +import { EditorState } from "@tiptap/pm/state"; + +export const hasListItemBefore = ( + typeOrName: string, + state: EditorState, +): boolean => { + const { $anchor } = state.selection; + + const $targetPos = state.doc.resolve($anchor.pos - 2); + + if ($targetPos.index() === 0) { + return false; + } + + if ($targetPos.nodeBefore?.type.name !== typeOrName) { + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts new file mode 100644 index 000000000..644953b92 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts @@ -0,0 +1,9 @@ +export * from "./find-list-item-pos"; +export * from "./get-next-list-depth"; +export * from "./handle-backspace"; +export * from "./handle-delete"; +export * from "./has-list-before"; +export * from "./has-list-item-after"; +export * from "./has-list-item-before"; +export * from "./next-list-is-deeper"; +export * from "./next-list-is-higher"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts new file mode 100644 index 000000000..425458b2a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; +import { getNextListDepth } from "./get-next-list-depth"; + +export const nextListIsDeeper = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth > listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts new file mode 100644 index 000000000..8b853b5af --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; +import { getNextListDepth } from "./get-next-list-depth"; + +export const nextListIsHigher = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth < listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts new file mode 100644 index 000000000..b61695973 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -0,0 +1,94 @@ +import { Extension } from "@tiptap/core"; + +import { handleBackspace, handleDelete } from "./list-helpers"; + +export type ListKeymapOptions = { + listTypes: Array<{ + itemName: string; + wrapperNames: string[]; + }>; +}; + +export const ListKeymap = Extension.create({ + name: "listKeymap", + + addOptions() { + return { + listTypes: [ + { + itemName: "listItem", + wrapperNames: ["bulletList", "orderedList"], + }, + { + itemName: "taskItem", + wrapperNames: ["taskList"], + }, + ], + }; + }, + + addKeyboardShortcuts() { + return { + Delete: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Delete": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + Backspace: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Backspace": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + }; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx new file mode 100644 index 000000000..0e3b5fe94 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx @@ -0,0 +1,116 @@ +import { TextSelection } from "prosemirror-state"; + +import { + InputRule, + mergeAttributes, + Node, + nodeInputRule, + wrappingInputRule, +} from "@tiptap/core"; + +/** + * Extension based on: + * - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule) + */ + +export interface HorizontalRuleOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + horizontalRule: { + /** + * Add a horizontal rule + */ + setHorizontalRule: () => ReturnType; + }; + } +} + +export default Node.create({ + name: "horizontalRule", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + group: "block", + + addAttributes() { + return { + color: { + default: "#dddddd", + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name, + }), + ["div", {}], + ]; + }, + + addCommands() { + return { + setHorizontalRule: + () => + ({ chain }) => { + return ( + chain() + .insertContent({ type: this.name }) + // set cursor after horizontal rule + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); + + if ($to.nodeAfter) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } else { + // add node after horizontal rule if it’s the end of the document + const node = + $to.parent.type.contentMatch.defaultType?.create(); + + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter)); + } + } + + tr.scrollIntoView(); + } + + return true; + }) + .run() + ); + }, + }; + }, + + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, match }) => { + state.tr.replaceRangeWith(range.from, range.to, this.type.create()); + }, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/image/image-resize.tsx b/packages/editor/core/src/ui/extensions/image/image-resize.tsx index 2545c7e44..2ede63961 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/core/src/ui/extensions/image/image-resize.tsx @@ -1,4 +1,5 @@ import { Editor } from "@tiptap/react"; +import { useState } from "react"; import Moveable from "react-moveable"; export const ImageResizer = ({ editor }: { editor: Editor }) => { @@ -17,6 +18,8 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => { } }; + const [aspectRatio, setAspectRatio] = useState(1); + return ( <> { keepRatio resizable throttleResize={0} + onResizeStart={() => { + const imageInfo = document.querySelector( + ".ProseMirror-selectednode", + ) as HTMLImageElement; + if (imageInfo) { + const originalWidth = Number(imageInfo.width); + const originalHeight = Number(imageInfo.height); + setAspectRatio(originalWidth / originalHeight); + } + }} onResize={({ target, width, height, delta }: any) => { - delta[0] && (target!.style.width = `${width}px`); - delta[1] && (target!.style.height = `${height}px`); + if (delta[0]) { + const newWidth = Math.max(width, 100); + const newHeight = newWidth / aspectRatio; + target!.style.width = `${newWidth}px`; + target!.style.height = `${newHeight}px`; + } + if (delta[1]) { + const newHeight = Math.max(height, 100); + const newWidth = newHeight * aspectRatio; + target!.style.height = `${newHeight}px`; + target!.style.width = `${newWidth}px`; + } }} onResizeEnd={() => { updateMediaSize(); diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index aea84c6b8..094a198bd 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -1,19 +1,135 @@ -import Image from "@tiptap/extension-image"; -import TrackImageDeletionPlugin from "../../plugins/delete-image"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import UploadImagesPlugin from "../../plugins/upload-image"; -import { DeleteImage } from "../../../types/delete-image"; +import ImageExt from "@tiptap/extension-image"; +import { onNodeDeleted, onNodeRestored } from "../../plugins/delete-image"; +import { DeleteImage, RestoreImage } from "@plane/editor-types"; + +interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +const deleteKey = new PluginKey("delete-image"); +const IMAGE_NODE_TYPE = "image"; const ImageExtension = ( deleteImage: DeleteImage, + restoreFile: RestoreImage, cancelUploadImage?: () => any, ) => - Image.extend({ + ImageExt.extend({ addProseMirrorPlugins() { return [ UploadImagesPlugin(cancelUploadImage), - TrackImageDeletionPlugin(deleteImage), + new Plugin({ + key: deleteKey, + appendTransaction: ( + transactions: readonly Transaction[], + oldState: EditorState, + newState: EditorState, + ) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + // transaction could be a selection + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((oldNode, oldPos) => { + // if the node is not an image, then return as no point in checking + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + + // Check if the node has been deleted or replaced + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + this.storage.images.set(src, true); + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }), + new Plugin({ + key: new PluginKey("imageRestoration"), + appendTransaction: ( + transactions: readonly Transaction[], + oldState: EditorState, + newState: EditorState, + ) => { + const oldImageSources = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + oldImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const addedImages: ImageNode[] = []; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== IMAGE_NODE_TYPE) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldImageSources.has(node.attrs.src)) return; + addedImages.push(node as ImageNode); + }); + + addedImages.forEach(async (image) => { + const wasDeleted = this.storage.images.get(image.attrs.src); + if (wasDeleted === undefined) { + this.storage.images.set(image.attrs.src, false); + } else if (wasDeleted === true) { + await onNodeRestored(image.attrs.src, restoreFile); + } + }); + }); + return null; + }, + }), ]; }, + + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreFile(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + // storage to keep track of image states Map + addStorage() { + return { + images: new Map(), + }; + }, + addAttributes() { return { ...this.parent?.(), diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 3f191a912..c911c886a 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -6,27 +6,36 @@ import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; -import Gapcursor from "@tiptap/extension-gapcursor"; import TableHeader from "./table/table-header/table-header"; import Table from "./table/table"; import TableCell from "./table/table-cell/table-cell"; import TableRow from "./table/table-row/table-row"; +import HorizontalRule from "./horizontal-rule"; import ImageExtension from "./image"; -import { DeleteImage } from "../../types/delete-image"; import { isValidHttpUrl } from "../../lib/utils"; -import { IMentionSuggestion } from "../../types/mention-suggestion"; import { Mentions } from "../mentions"; +import { CustomKeymap } from "./keymap"; +import { CustomCodeBlock } from "./code"; +import { CustomQuoteExtension } from "./quote"; +import { ListKeymap } from "./custom-list-keymap"; +import { + IMentionSuggestion, + DeleteImage, + RestoreImage, +} from "@plane/editor-types"; + export const CoreEditorExtensions = ( mentionConfig: { mentionSuggestions: IMentionSuggestion[]; mentionHighlights: string[]; }, deleteFile: DeleteImage, - cancelUploadImage?: () => any, + restoreFile: RestoreImage, + cancelUploadImage?: () => any ) => [ StarterKit.configure({ bulletList: { @@ -44,27 +53,24 @@ export const CoreEditorExtensions = ( class: "leading-normal -mb-2", }, }, - blockquote: { - HTMLAttributes: { - class: "border-l-4 border-custom-border-300", - }, - }, - code: { - HTMLAttributes: { - class: - "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + // blockquote: { + // HTMLAttributes: { + // class: "border-l-4 border-custom-border-300", + // }, + // }, + code: false, codeBlock: false, horizontalRule: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, }, - gapcursor: false, }), - Gapcursor, + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomKeymap, + ListKeymap, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -73,7 +79,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtension(deleteFile, cancelUploadImage).configure({ + ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", }, @@ -86,6 +92,7 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), + CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", @@ -95,7 +102,9 @@ export const CoreEditorExtensions = ( Markdown.configure({ html: true, transformCopiedText: true, + transformPastedText: true, }), + HorizontalRule, Table, TableHeader, TableCell, @@ -103,6 +112,6 @@ export const CoreEditorExtensions = ( Mentions( mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, - false, + false ), ]; diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/core/src/ui/extensions/keymap.tsx new file mode 100644 index 000000000..0caa194cd --- /dev/null +++ b/packages/editor/core/src/ui/extensions/keymap.tsx @@ -0,0 +1,54 @@ +import { Extension } from "@tiptap/core"; + +declare module "@tiptap/core" { + // eslint-disable-next-line no-unused-vars + interface Commands { + customkeymap: { + /** + * Select text between node boundaries + */ + selectTextWithinNodeBoundaries: () => ReturnType; + }; + } +} + +export const CustomKeymap = Extension.create({ + name: "CustomKeymap", + + addCommands() { + return { + selectTextWithinNodeBoundaries: + () => + ({ editor, commands }) => { + const { state } = editor; + const { tr } = state; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + return commands.setTextSelection({ + from: startNodePos, + to: endNodePos, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-a": ({ editor }) => { + const { state } = editor; + const { tr } = state; + const startSelectionPos = tr.selection.from; + const endSelectionPos = tr.selection.to; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + const isCurrentTextSelectionNotExtendedToNodeBoundaries = + startSelectionPos > startNodePos || endSelectionPos < endNodePos; + if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { + editor.chain().selectTextWithinNodeBoundaries().run(); + return true; + } + return false; + }, + }; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/quote/index.tsx b/packages/editor/core/src/ui/extensions/quote/index.tsx new file mode 100644 index 000000000..a2c968401 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/quote/index.tsx @@ -0,0 +1,26 @@ +import { isAtStartOfNode } from "@tiptap/core"; +import Blockquote from "@tiptap/extension-blockquote"; + +export const CustomQuoteExtension = Blockquote.extend({ + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { $from, $to, $head } = this.editor.state.selection; + const parent = $head.node(-1); + + if (!parent) return false; + + if (parent.type.name !== "blockquote") { + return false; + } + if ($from.pos !== $to.pos) return false; + // if ($head.parentOffset < $head.parent.content.size) return false; + + // this.editor.commands.insertContentAt(parent.ne); + this.editor.chain().splitBlock().lift(this.name).run(); + + return true; + }, + }; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index eda520759..65e8b8540 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,11 +1,10 @@ const icons = { - colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + colorPicker: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` any; setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void; setShouldShowAlert?: (showAlert: boolean) => void; value: string; - deleteFile: DeleteImage; debouncedUpdatesEnabled?: boolean; + onStart?: (json: any, html: string) => void; onChange?: (json: any, html: string) => void; extensions?: any; editorProps?: EditorProps; forwardedRef?: any; mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; - cancelUploadImage?: () => any; } export const useEditor = ({ @@ -38,10 +41,13 @@ export const useEditor = ({ cancelUploadImage, editorProps = {}, value, + rerenderOnPropsChange, extensions = [], + onStart, onChange, setIsSubmitting, forwardedRef, + restoreFile, setShouldShowAlert, mentionHighlights, mentionSuggestions, @@ -59,12 +65,16 @@ export const useEditor = ({ mentionHighlights: mentionHighlights ?? [], }, deleteFile, + restoreFile, cancelUploadImage, ), ...extensions, ], content: typeof value === "string" && value.trim() !== "" ? value : "

", + onCreate: async ({ editor }) => { + onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); + }, onUpdate: async ({ editor }) => { // for instant feedback loop setIsSubmitting?.("submitting"); @@ -72,11 +82,9 @@ export const useEditor = ({ onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); }, }, - [], + [rerenderOnPropsChange], ); - useInitializedContent(editor, value); - const editorRef: MutableRefObject = useRef(null); editorRef.current = editor; diff --git a/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx b/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx new file mode 100644 index 000000000..3339e095f --- /dev/null +++ b/packages/editor/core/src/ui/hooks/use-read-only-editor.tsx @@ -0,0 +1,67 @@ +import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useImperativeHandle, useRef, MutableRefObject } from "react"; +import { CoreReadOnlyEditorExtensions } from "../read-only/extensions"; +import { CoreReadOnlyEditorProps } from "../read-only/props"; +import { EditorProps } from "@tiptap/pm/view"; +import { IMentionSuggestion } from "@plane/editor-types"; + +interface CustomReadOnlyEditorProps { + value: string; + forwardedRef?: any; + extensions?: any; + editorProps?: EditorProps; + rerenderOnPropsChange?: { + id: string; + description_html: string; + }; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; +} + +export const useReadOnlyEditor = ({ + value, + forwardedRef, + extensions = [], + editorProps = {}, + rerenderOnPropsChange, + mentionHighlights, + mentionSuggestions, +}: CustomReadOnlyEditorProps) => { + const editor = useCustomEditor( + { + editable: false, + content: + typeof value === "string" && value.trim() !== "" ? value : "

", + editorProps: { + ...CoreReadOnlyEditorProps, + ...editorProps, + }, + extensions: [ + ...CoreReadOnlyEditorExtensions({ + mentionSuggestions: mentionSuggestions ?? [], + mentionHighlights: mentionHighlights ?? [], + }), + ...extensions, + ], + }, + [rerenderOnPropsChange], + ); + + const editorRef: MutableRefObject = useRef(null); + editorRef.current = editor; + + useImperativeHandle(forwardedRef, () => ({ + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + })); + + if (!editor) { + return null; + } + + return editor; +}; diff --git a/packages/editor/core/src/ui/hooks/useInitializedContent.tsx b/packages/editor/core/src/ui/hooks/useInitializedContent.tsx deleted file mode 100644 index 8e2ce1717..000000000 --- a/packages/editor/core/src/ui/hooks/useInitializedContent.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { useEffect, useRef } from "react"; - -export const useInitializedContent = (editor: Editor | null, value: string) => { - const hasInitializedContent = useRef(false); - - useEffect(() => { - if (editor) { - const cleanedValue = - typeof value === "string" && value.trim() !== "" ? value : "

"; - if (cleanedValue !== "

" && !hasInitializedContent.current) { - editor.commands.setContent(cleanedValue); - hasInitializedContent.current = true; - } else if (cleanedValue === "

" && hasInitializedContent.current) { - hasInitializedContent.current = false; - } - } - }, [value, editor]); -}; diff --git a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx deleted file mode 100644 index 75ebddd3c..000000000 --- a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; -import { - useImperativeHandle, - useRef, - MutableRefObject, - useEffect, -} from "react"; -import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions"; -import { CoreReadOnlyEditorProps } from "../../ui/read-only/props"; -import { EditorProps } from "@tiptap/pm/view"; -import { IMentionSuggestion } from "../../types/mention-suggestion"; - -interface CustomReadOnlyEditorProps { - value: string; - forwardedRef?: any; - extensions?: any; - editorProps?: EditorProps; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; -} - -export const useReadOnlyEditor = ({ - value, - forwardedRef, - extensions = [], - editorProps = {}, - mentionHighlights, - mentionSuggestions, -}: CustomReadOnlyEditorProps) => { - const editor = useCustomEditor({ - editable: false, - content: - typeof value === "string" && value.trim() !== "" ? value : "

", - editorProps: { - ...CoreReadOnlyEditorProps, - ...editorProps, - }, - extensions: [ - ...CoreReadOnlyEditorExtensions({ - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], - }), - ...extensions, - ], - }); - - const hasIntiliazedContent = useRef(false); - useEffect(() => { - if (editor && !value && !hasIntiliazedContent.current) { - editor.commands.setContent(value); - hasIntiliazedContent.current = true; - } - }, [value]); - - const editorRef: MutableRefObject = useRef(null); - editorRef.current = editor; - - useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); - }, - setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); - }, - })); - - if (!editor) { - return null; - } - - return editor; -}; diff --git a/packages/editor/core/src/ui/index.tsx b/packages/editor/core/src/ui/index.tsx deleted file mode 100644 index a314a2650..000000000 --- a/packages/editor/core/src/ui/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; -import * as React from "react"; -import { Extension } from "@tiptap/react"; -import { UploadImage } from "../types/upload-image"; -import { DeleteImage } from "../types/delete-image"; -import { getEditorClassNames } from "../lib/utils"; -import { EditorProps } from "@tiptap/pm/view"; -import { useEditor } from "./hooks/useEditor"; -import { EditorContainer } from "../ui/components/editor-container"; -import { EditorContentWrapper } from "../ui/components/editor-content"; -import { IMentionSuggestion } from "../types/mention-suggestion"; - -interface ICoreEditor { - value: string; - uploadFile: UploadImage; - deleteFile: DeleteImage; - noBorder?: boolean; - borderOnFocus?: boolean; - customClassName?: string; - editorContentCustomClassNames?: string; - onChange?: (json: any, html: string) => void; - setIsSubmitting?: ( - isSubmitting: "submitting" | "submitted" | "saved", - ) => void; - setShouldShowAlert?: (showAlert: boolean) => void; - editable?: boolean; - forwardedRef?: any; - debouncedUpdatesEnabled?: boolean; - accessValue: string; - onAccessChange: (accessKey: string) => void; - commentAccess: { - icon: string; - key: string; - label: "Private" | "Public"; - }[]; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; - extensions?: Extension[]; - editorProps?: EditorProps; -} - -interface EditorCoreProps extends ICoreEditor { - forwardedRef?: React.Ref; -} - -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; -} - -const CoreEditor = ({ - onChange, - debouncedUpdatesEnabled, - editable, - setIsSubmitting, - setShouldShowAlert, - editorContentCustomClassNames, - value, - uploadFile, - deleteFile, - noBorder, - borderOnFocus, - customClassName, - forwardedRef, -}: EditorCoreProps) => { - const editor = useEditor({ - onChange, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, - value, - uploadFile, - deleteFile, - forwardedRef, - }); - - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, - }); - - if (!editor) return null; - - return ( - -
- -
-
- ); -}; - -const CoreEditorWithRef = React.forwardRef( - (props, ref) => , -); - -CoreEditorWithRef.displayName = "CoreEditorWithRef"; - -export { CoreEditor, CoreEditorWithRef }; diff --git a/packages/editor/core/src/ui/mentions/MentionList.tsx b/packages/editor/core/src/ui/mentions/MentionList.tsx index 48aebaa11..1bc4dc4d1 100644 --- a/packages/editor/core/src/ui/mentions/MentionList.tsx +++ b/packages/editor/core/src/ui/mentions/MentionList.tsx @@ -1,3 +1,4 @@ +import { IMentionSuggestion } from "@plane/editor-types"; import { Editor } from "@tiptap/react"; import React, { forwardRef, @@ -7,8 +8,6 @@ import React, { useState, } from "react"; -import { IMentionSuggestion } from "../../types/mention-suggestion"; - interface MentionListProps { items: IMentionSuggestion[]; command: (item: { diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index dc4ab5aad..e25da6f47 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -2,7 +2,8 @@ import { Mention, MentionOptions } from "@tiptap/extension-mention"; import { mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import mentionNodeView from "./mentionNodeView"; -import { IMentionHighlight } from "../../types/mention-suggestion"; +import { IMentionHighlight } from "@plane/editor-types"; + export interface CustomMentionOptions extends MentionOptions { mentionHighlights: IMentionHighlight[]; readonly?: boolean; diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index 42ec92554..acbea8f59 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -2,10 +2,7 @@ import suggestion from "./suggestion"; import { CustomMention } from "./custom"; -import { - IMentionHighlight, - IMentionSuggestion, -} from "../../types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "@plane/editor-types"; export const Mentions = ( mentionSuggestions: IMentionSuggestion[], diff --git a/packages/editor/core/src/ui/mentions/mentionNodeView.tsx b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx index 331c701e2..1451b0b22 100644 --- a/packages/editor/core/src/ui/mentions/mentionNodeView.tsx +++ b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx @@ -3,7 +3,7 @@ import { NodeViewWrapper } from "@tiptap/react"; import { cn } from "../../lib/utils"; import { useRouter } from "next/router"; -import { IMentionHighlight } from "../../types/mention-suggestion"; +import { IMentionHighlight } from "@plane/editor-types"; // eslint-disable-next-line import/no-anonymous-default-export export default (props) => { diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index ce09cb092..920808c1a 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -3,7 +3,7 @@ import { Editor } from "@tiptap/core"; import tippy from "tippy.js"; import MentionList from "./MentionList"; -import { IMentionSuggestion } from "../../types/mention-suggestion"; +import { IMentionSuggestion } from "@plane/editor-types"; const Suggestion = (suggestions: IMentionSuggestion[]) => ({ items: ({ query }: { query: string }) => diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index 8a2651d1e..3a78c94e3 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -15,14 +15,13 @@ import { CodeIcon, } from "lucide-react"; import { Editor } from "@tiptap/react"; -import { UploadImage } from "../../../types/upload-image"; import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, - toggleCode, + toggleCodeBlock, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, @@ -32,6 +31,7 @@ import { toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands"; +import { UploadImage } from "@plane/editor-types"; export interface EditorMenuItem { name: string; @@ -89,13 +89,6 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ icon: StrikethroughIcon, }); -export const CodeItem = (editor: Editor): EditorMenuItem => ({ - name: "code", - isActive: () => editor?.isActive("code"), - command: () => toggleCode(editor), - icon: CodeIcon, -}); - export const BulletListItem = (editor: Editor): EditorMenuItem => ({ name: "bullet-list", isActive: () => editor?.isActive("bulletList"), @@ -110,6 +103,13 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({ icon: CheckSquare, }); +export const CodeItem = (editor: Editor): EditorMenuItem => ({ + name: "code", + isActive: () => editor?.isActive("code"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, +}); + export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ name: "ordered-list", isActive: () => editor?.isActive("orderedList"), diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx index 48ec244fc..6f4bd46be 100644 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ b/packages/editor/core/src/ui/plugins/delete-image.tsx @@ -1,6 +1,6 @@ import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { DeleteImage } from "../../types/delete-image"; +import { DeleteImage, RestoreImage } from "@plane/editor-types"; const deleteKey = new PluginKey("delete-image"); const IMAGE_NODE_TYPE = "image"; @@ -59,7 +59,7 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => export default TrackImageDeletionPlugin; -async function onNodeDeleted( +export async function onNodeDeleted( src: string, deleteImage: DeleteImage, ): Promise { @@ -73,3 +73,18 @@ async function onNodeDeleted( console.error("Error deleting image: ", error); } } + +export async function onNodeRestored( + src: string, + restoreImage: RestoreImage, +): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + const resStatus = await restoreImage(assetUrlWithWorkspaceId); + if (resStatus === 204) { + console.log("Image restored successfully"); + } + } catch (error) { + console.error("Error restoring image: ", error); + } +} diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx index 256460073..ea40c3c71 100644 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core/src/ui/plugins/upload-image.tsx @@ -1,4 +1,4 @@ -import { UploadImage } from "../../types/upload-image"; +import { UploadImage } from "@plane/editor-types"; import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 865e0d2c7..24a08d844 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -1,7 +1,7 @@ +import { UploadImage } from "@plane/editor-types"; import { EditorProps } from "@tiptap/pm/view"; import { findTableAncestor } from "../lib/utils"; import { startImageUpload } from "./plugins/upload-image"; -import { UploadImage } from "../types/upload-image"; export function CoreEditorProps( uploadFile: UploadImage, @@ -82,5 +82,8 @@ export function CoreEditorProps( } return false; }, + transformPastedHTML(html) { + return html.replace(//g, ""); + }, }; } diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index b8fc9bb95..f7ebedccc 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -16,7 +16,7 @@ import TableRow from "../extensions/table/table-row/table-row"; import ReadOnlyImageExtension from "../extensions/image/read-only-image"; import { isValidHttpUrl } from "../../lib/utils"; import { Mentions } from "../mentions"; -import { IMentionSuggestion } from "../../types/mention-suggestion"; +import { IMentionSuggestion } from "@plane/editor-types"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionSuggestions: IMentionSuggestion[]; diff --git a/packages/editor/document-editor/Readme.md b/packages/editor/document-editor/Readme.md new file mode 100644 index 000000000..f019d6827 --- /dev/null +++ b/packages/editor/document-editor/Readme.md @@ -0,0 +1 @@ +# Document Editor diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json new file mode 100644 index 000000000..c716536da --- /dev/null +++ b/packages/editor/document-editor/package.json @@ -0,0 +1,66 @@ +{ + "name": "@plane/document-editor", + "version": "0.1.0", + "description": "Package that powers Plane's Pages Editor", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsup --minify", + "dev": "tsup --watch", + "check-types": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,tsx,md}\"" + }, + "peerDependencies": { + "next": "12.3.2", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" + }, + "dependencies": { + "@plane/editor-core": "*", + "@plane/editor-extensions": "*", + "@plane/editor-types": "*", + "@plane/ui": "*", + "@tiptap/core": "^2.1.7", + "@tiptap/extension-placeholder": "^2.1.11", + "@tiptap/pm": "^2.1.12", + "@tiptap/suggestion": "^2.1.12", + "@types/node": "18.15.3", + "@types/react": "^18.2.39", + "@types/react-dom": "18.0.11", + "eslint": "8.36.0", + "eslint-config-next": "13.2.4", + "react-popper": "^2.3.0", + "tippy.js": "^6.3.7", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/node": "18.15.3", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "eslint": "^7.32.0", + "postcss": "^8.4.29", + "tailwind-config-custom": "*", + "tsconfig": "*", + "tsup": "^7.2.0", + "typescript": "4.9.5" + }, + "keywords": [ + "editor", + "rich-text", + "markdown", + "nextjs", + "react" + ] +} \ No newline at end of file diff --git a/packages/editor/document-editor/postcss.config.js b/packages/editor/document-editor/postcss.config.js new file mode 100644 index 000000000..419fe25d1 --- /dev/null +++ b/packages/editor/document-editor/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + }; \ No newline at end of file diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts new file mode 100644 index 000000000..ae48a3b47 --- /dev/null +++ b/packages/editor/document-editor/src/index.ts @@ -0,0 +1,6 @@ +export { DocumentEditor, DocumentEditorWithRef } from "./ui"; +export { + DocumentReadOnlyEditor, + DocumentReadOnlyEditorWithRef, +} from "./ui/readonly"; +export { FixedMenu } from "./ui/menu/fixed-menu"; diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx new file mode 100644 index 000000000..0f0a238ba --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/alert-label.tsx @@ -0,0 +1,21 @@ +import { Icon } from "lucide-react"; + +interface IAlertLabelProps { + Icon?: Icon; + backgroundColor: string; + textColor?: string; + label: string; +} + +export const AlertLabel = (props: IAlertLabelProps) => { + const { Icon, backgroundColor, textColor, label } = props; + + return ( +
+ {Icon && } + {label} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx new file mode 100644 index 000000000..68f6469b8 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -0,0 +1,49 @@ +import { + HeadingComp, + HeadingThreeComp, + SubheadingComp, +} from "./heading-component"; +import { IMarking } from ".."; +import { Editor } from "@tiptap/react"; +import { scrollSummary } from "../utils/editor-summary-utils"; + +interface ContentBrowserProps { + editor: Editor; + markings: IMarking[]; +} + +export const ContentBrowser = (props: ContentBrowserProps) => { + const { editor, markings } = props; + + return ( +
+

Table of Contents

+
+ {markings.length !== 0 ? ( + markings.map((marking) => + marking.level === 1 ? ( + scrollSummary(editor, marking)} + heading={marking.text} + /> + ) : marking.level === 2 ? ( + scrollSummary(editor, marking)} + subHeading={marking.text} + /> + ) : ( + scrollSummary(editor, marking)} + /> + ), + ) + ) : ( +

+ Headings will be displayed here for navigation +

+ )} +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx new file mode 100644 index 000000000..6d548669e --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -0,0 +1,108 @@ +import { Editor } from "@tiptap/react"; +import { Archive, RefreshCw, Lock } from "lucide-react"; +import { IMarking } from ".."; +import { FixedMenu } from "../menu"; +import { UploadImage } from "@plane/editor-types"; +import { DocumentDetails } from "../types/editor-types"; +import { AlertLabel } from "./alert-label"; +import { + IVerticalDropdownItemProps, + VerticalDropdownMenu, +} from "./vertical-dropdown-menu"; +import { SummaryPopover } from "./summary-popover"; +import { InfoPopover } from "./info-popover"; + +interface IEditorHeader { + editor: Editor; + KanbanMenuOptions: IVerticalDropdownItemProps[]; + sidePeekVisible: boolean; + setSidePeekVisible: (sidePeekState: boolean) => void; + markings: IMarking[]; + isLocked: boolean; + isArchived: boolean; + archivedAt?: Date; + readonly: boolean; + uploadFile?: UploadImage; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; + documentDetails: DocumentDetails; + isSubmitting?: "submitting" | "submitted" | "saved"; +} + +export const EditorHeader = (props: IEditorHeader) => { + const { + documentDetails, + archivedAt, + editor, + sidePeekVisible, + readonly, + setSidePeekVisible, + markings, + uploadFile, + setIsSubmitting, + KanbanMenuOptions, + isArchived, + isLocked, + isSubmitting, + } = props; + + return ( +
+
+ +
+ +
+ {!readonly && uploadFile && ( + + )} +
+ +
+ {isLocked && ( + + )} + {isArchived && archivedAt && ( + + )} + + {!isLocked && !isArchived ? ( +
+ {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( + + )} + + {isSubmitting === "submitting" ? "Saving..." : "Saved"} + +
+ ) : null} + {!isArchived && } + +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx new file mode 100644 index 000000000..d8ceea8f9 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/heading-component.tsx @@ -0,0 +1,47 @@ +export const HeadingComp = ({ + heading, + onClick, +}: { + heading: string; + onClick: (event: React.MouseEvent) => void; +}) => ( +

+ {heading} +

+); + +export const SubheadingComp = ({ + subHeading, + onClick, +}: { + subHeading: string; + onClick: (event: React.MouseEvent) => void; +}) => ( +

+ {subHeading} +

+); + +export const HeadingThreeComp = ({ + heading, + onClick, +}: { + heading: string; + onClick: (event: React.MouseEvent) => void; +}) => ( +

+ {heading} +

+); diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts new file mode 100644 index 000000000..1496a3cf4 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/index.ts @@ -0,0 +1,9 @@ +export * from "./alert-label"; +export * from "./content-browser"; +export * from "./editor-header"; +export * from "./heading-component"; +export * from "./info-popover"; +export * from "./page-renderer"; +export * from "./summary-popover"; +export * from "./summary-side-bar"; +export * from "./vertical-dropdown-menu"; diff --git a/packages/editor/document-editor/src/ui/components/info-popover.tsx b/packages/editor/document-editor/src/ui/components/info-popover.tsx new file mode 100644 index 000000000..41d131afb --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/info-popover.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { usePopper } from "react-popper"; +import { Calendar, History, Info } from "lucide-react"; +// types +import { DocumentDetails } from "../types/editor-types"; + +type Props = { + documentDetails: DocumentDetails; +}; + +// function to render a Date in the format- 25 May 2023 at 2:53PM +const renderDate = (date: Date): string => { + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "long", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }; + + const formattedDate: string = new Intl.DateTimeFormat( + "en-US", + options, + ).format(date); + + return formattedDate; +}; + +export const InfoPopover: React.FC = (props) => { + const { documentDetails } = props; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const [referenceElement, setReferenceElement] = + useState(null); + const [popperElement, setPopperElement] = useState( + null, + ); + + const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = + usePopper(referenceElement, popperElement, { + placement: "bottom-start", + }); + + return ( +
setIsPopoverOpen(true)} + onMouseLeave={() => setIsPopoverOpen(false)} + > + + {isPopoverOpen && ( +
+
+
Last updated on
+
+ + {renderDate(new Date(documentDetails.last_updated_at))} +
+
+
+
Created on
+
+ + {renderDate(new Date(documentDetails.created_on))} +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx new file mode 100644 index 000000000..198a03b64 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -0,0 +1,72 @@ +import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; +import { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { DocumentDetails } from "../types/editor-types"; + +type IPageRenderer = { + documentDetails: DocumentDetails; + updatePageTitle: (title: string) => Promise; + editor: Editor; + editorClassNames: string; + editorContentCustomClassNames?: string; + readonly: boolean; +}; + +const debounce = (func: (...args: any[]) => void, wait: number) => { + let timeout: NodeJS.Timeout | null = null; + return function executedFunction(...args: any[]) { + const later = () => { + if (timeout) clearTimeout(timeout); + func(...args); + }; + if (timeout) clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +export const PageRenderer = (props: IPageRenderer) => { + const { + documentDetails, + editor, + editorClassNames, + editorContentCustomClassNames, + updatePageTitle, + readonly, + } = props; + + const [pageTitle, setPagetitle] = useState(documentDetails.title); + + const debouncedUpdatePageTitle = debounce(updatePageTitle, 300); + + const handlePageTitleChange = (title: string) => { + setPagetitle(title); + debouncedUpdatePageTitle(title); + }; + + return ( +
+ {!readonly ? ( + handlePageTitleChange(e.target.value)} + className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none" + value={pageTitle} + /> + ) : ( + handlePageTitleChange(e.target.value)} + className="text-4xl bg-custom-background font-bold break-words pr-5 -mt-2 w-full border-none outline-none overflow-x-clip" + value={pageTitle} + disabled + /> + )} +
+ + + +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/summary-popover.tsx b/packages/editor/document-editor/src/ui/components/summary-popover.tsx new file mode 100644 index 000000000..67054212d --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/summary-popover.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Editor } from "@tiptap/react"; +import { usePopper } from "react-popper"; +import { List } from "lucide-react"; +// components +import { ContentBrowser } from "./content-browser"; +// types +import { IMarking } from ".."; + +type Props = { + editor: Editor; + markings: IMarking[]; + sidePeekVisible: boolean; + setSidePeekVisible: (sidePeekState: boolean) => void; +}; + +export const SummaryPopover: React.FC = (props) => { + const { editor, markings, sidePeekVisible, setSidePeekVisible } = props; + + const [referenceElement, setReferenceElement] = + useState(null); + const [popperElement, setPopperElement] = useState( + null, + ); + + const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = + usePopper(referenceElement, popperElement, { + placement: "bottom-start", + }); + + return ( +
+ + {!sidePeekVisible && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx new file mode 100644 index 000000000..545109735 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx @@ -0,0 +1,25 @@ +import { Editor } from "@tiptap/react"; +import { IMarking } from ".."; +import { ContentBrowser } from "./content-browser"; + +interface ISummarySideBarProps { + editor: Editor; + markings: IMarking[]; + sidePeekVisible: boolean; +} + +export const SummarySideBar = ({ + editor, + markings, + sidePeekVisible, +}: ISummarySideBarProps) => { + return ( +
+ +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx new file mode 100644 index 000000000..fd897b156 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx @@ -0,0 +1,61 @@ +import { Button, CustomMenu } from "@plane/ui"; +import { ChevronUp, Icon, MoreVertical } from "lucide-react"; + +type TMenuItems = + | "archive_page" + | "unarchive_page" + | "lock_page" + | "unlock_page" + | "copy_markdown" + | "close_page" + | "copy_page_link" + | "duplicate_page"; + +export interface IVerticalDropdownItemProps { + key: number; + type: TMenuItems; + Icon: Icon; + label: string; + action: () => Promise | void; +} + +export interface IVerticalDropdownMenuProps { + items: IVerticalDropdownItemProps[]; +} + +const VerticalDropdownItem = ({ + Icon, + label, + action, +}: IVerticalDropdownItemProps) => { + return ( + + +
{label}
+
+ ); +}; + +export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => { + return ( + } + > + {items.map((item, index) => ( + + ))} + + ); +}; diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx new file mode 100644 index 000000000..a5b77202f --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -0,0 +1,56 @@ +import Placeholder from "@tiptap/extension-placeholder"; +import { IssueWidgetExtension } from "./widgets/IssueEmbedWidget"; + +import { IIssueEmbedConfig } from "./widgets/IssueEmbedWidget/types"; + +import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; +import { ISlashCommandItem, UploadImage } from "@plane/editor-types"; +import { IssueSuggestions } from "./widgets/IssueEmbedSuggestionList"; +import { LayersIcon } from "@plane/ui"; + +export const DocumentEditorExtensions = ( + uploadFile: UploadImage, + issueEmbedConfig?: IIssueEmbedConfig, + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, +) => { + const additonalOptions: ISlashCommandItem[] = [ + { + title: "Issue Embed", + description: "Embed an issue from the project", + searchTerms: ["Issue", "Iss"], + icon: , + command: ({ editor, range }) => { + editor + .chain() + .focus() + .insertContentAt( + range, + "

#issue_

", + ) + .run(); + }, + }, + ]; + + return [ + SlashCommand(uploadFile, setIsSubmitting, additonalOptions), + DragAndDrop, + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + IssueWidgetExtension({ issueEmbedConfig }), + IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []), + ]; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx new file mode 100644 index 000000000..54d1dec21 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/index.tsx @@ -0,0 +1,56 @@ +import { Editor, Range } from "@tiptap/react"; +import { IssueEmbedSuggestions } from "./issue-suggestion-extension"; +import { getIssueSuggestionItems } from "./issue-suggestion-items"; +import { IssueListRenderer } from "./issue-suggestion-renderer"; +import { v4 as uuidv4 } from "uuid"; + +export type CommandProps = { + editor: Editor; + range: Range; +}; + +export interface IIssueListSuggestion { + title: string; + priority: "high" | "low" | "medium" | "urgent"; + identifier: string; + state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; + command: ({ editor, range }: CommandProps) => void; +} + +export const IssueSuggestions = (suggestions: any[]) => { + const mappedSuggestions: IIssueListSuggestion[] = suggestions.map( + (suggestion): IIssueListSuggestion => { + let transactionId = uuidv4(); + return { + title: suggestion.name, + priority: suggestion.priority.toString(), + identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, + state: suggestion.state_detail.name, + command: ({ editor, range }) => { + editor + .chain() + .focus() + .insertContentAt(range, { + type: "issue-embed-component", + attrs: { + entity_identifier: suggestion.id, + id: transactionId, + title: suggestion.name, + project_identifier: suggestion.project_detail.identifier, + sequence_id: suggestion.sequence_id, + entity_name: "issue", + }, + }) + .run(); + }, + }; + }, + ); + + return IssueEmbedSuggestions.configure({ + suggestion: { + items: getIssueSuggestionItems(mappedSuggestions), + render: IssueListRenderer, + }, + }); +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx new file mode 100644 index 000000000..fbd31c257 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-extension.tsx @@ -0,0 +1,38 @@ +import { Extension, Range } from "@tiptap/core"; +import { PluginKey } from "@tiptap/pm/state"; +import { Editor } from "@tiptap/react"; +import Suggestion from "@tiptap/suggestion"; + +export const IssueEmbedSuggestions = Extension.create({ + name: "issue-embed-suggestions", + + addOptions() { + return { + suggestion: { + command: ({ + editor, + range, + props, + }: { + editor: Editor; + range: Range; + props: any; + }) => { + props.command({ editor, range }); + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + char: "#issue_", + pluginKey: new PluginKey("issue-embed-suggestions"), + editor: this.editor, + allowSpaces: true, + + ...this.options.suggestion, + }), + ]; + }, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx new file mode 100644 index 000000000..ae5e164a2 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-items.tsx @@ -0,0 +1,18 @@ +import { IIssueListSuggestion } from "."; + +export const getIssueSuggestionItems = ( + issueSuggestions: Array, +) => { + return ({ query }: { query: string }) => { + const search = query.toLowerCase(); + const filteredSuggestions = issueSuggestions.filter((item) => { + return ( + item.title.toLowerCase().includes(search) || + item.identifier.toLowerCase().includes(search) || + item.priority.toLowerCase().includes(search) + ); + }); + + return filteredSuggestions; + }; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx new file mode 100644 index 000000000..892a7f09b --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedSuggestionList/issue-suggestion-renderer.tsx @@ -0,0 +1,279 @@ +import { cn } from "@plane/editor-core"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; +import { ReactRenderer } from "@tiptap/react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { PriorityIcon } from "@plane/ui"; + +const updateScrollView = (container: HTMLElement, item: HTMLElement) => { + const containerHeight = container.offsetHeight; + const itemHeight = item ? item.offsetHeight : 0; + + const top = item.offsetTop; + const bottom = top + itemHeight; + + if (top < container.scrollTop) { + // container.scrollTop = top - containerHeight; + item.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } else if (bottom > containerHeight + container.scrollTop) { + // container.scrollTop = bottom - containerHeight; + item.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } +}; +interface IssueSuggestionProps { + title: string; + priority: "high" | "low" | "medium" | "urgent" | "none"; + state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; + identifier: string; +} + +const IssueSuggestionList = ({ + items, + command, + editor, +}: { + items: IssueSuggestionProps[]; + command: any; + editor: Editor; + range: any; +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [currentSection, setCurrentSection] = useState("Backlog"); + const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"]; + const [displayedItems, setDisplayedItems] = useState<{ + [key: string]: IssueSuggestionProps[]; + }>({}); + const [displayedTotalLength, setDisplayedTotalLength] = useState(0); + const commandListContainer = useRef(null); + + useEffect(() => { + let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; + let totalLength = 0; + sections.forEach((section) => { + newDisplayedItems[section] = items + .filter((item) => item.state === section) + .slice(0, 5); + + totalLength += newDisplayedItems[section].length; + }); + setDisplayedTotalLength(totalLength); + setDisplayedItems(newDisplayedItems); + }, [items]); + + const selectItem = useCallback( + (index: number) => { + const item = displayedItems[currentSection][index]; + if (item) { + command(item); + } + }, + [command, displayedItems, currentSection], + ); + + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + // if (editor.isFocused) { + // editor.chain().blur(); + // commandListContainer.current?.focus(); + // } + if (e.key === "ArrowUp") { + setSelectedIndex( + (selectedIndex + displayedItems[currentSection].length - 1) % + displayedItems[currentSection].length, + ); + return true; + } + if (e.key === "ArrowDown") { + const nextIndex = + (selectedIndex + 1) % displayedItems[currentSection].length; + setSelectedIndex(nextIndex); + if (nextIndex === 4) { + const nextItems = items + .filter((item) => item.state === currentSection) + .slice( + displayedItems[currentSection].length, + displayedItems[currentSection].length + 5, + ); + setDisplayedItems((prevItems) => ({ + ...prevItems, + [currentSection]: [...prevItems[currentSection], ...nextItems], + })); + } + return true; + } + if (e.key === "Enter") { + selectItem(selectedIndex); + return true; + } + if (e.key === "Tab") { + const currentSectionIndex = sections.indexOf(currentSection); + const nextSectionIndex = (currentSectionIndex + 1) % sections.length; + setCurrentSection(sections[nextSectionIndex]); + setSelectedIndex(0); + return true; + } + return false; + } else if (e.key === "Escape") { + if (!editor.isFocused) { + editor.chain().focus(); + } + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [ + displayedItems, + selectedIndex, + setSelectedIndex, + selectItem, + currentSection, + ]); + + useLayoutEffect(() => { + const container = commandListContainer?.current; + if (container) { + const sectionContainer = container?.querySelector( + `#${currentSection}-container`, + ) as HTMLDivElement; + if (sectionContainer) { + updateScrollView(container, sectionContainer); + } + const sectionScrollContainer = container?.querySelector( + `#${currentSection}`, + ) as HTMLElement; + const item = sectionScrollContainer?.children[ + selectedIndex + ] as HTMLElement; + if (item && sectionScrollContainer) { + updateScrollView(sectionScrollContainer, item); + } + } + }, [selectedIndex, currentSection]); + + return displayedTotalLength > 0 ? ( +
+ {sections.map((section) => { + const sectionItems = displayedItems[section]; + return ( + sectionItems && + sectionItems.length > 0 && ( +
+
+ {section} +
+
+ {sectionItems.map( + (item: IssueSuggestionProps, index: number) => ( + + ), + )} +
+
+ ) + ); + })} +
+ ) : null; +}; + +export const IssueListRenderer = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + component = new ReactRenderer(IssueSuggestionList, { + props, + // @ts-ignore + editor: props.editor, + }); + + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector("#editor-container"), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "right", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + return true; + } + // @ts-ignore + return component?.ref?.onKeyDown(props); + }, + onExit: (e) => { + popup?.[0].destroy(); + setTimeout(() => { + component?.destroy(); + }, 300); + }, + }; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx new file mode 100644 index 000000000..bbe8ec021 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/index.tsx @@ -0,0 +1,12 @@ +import { IssueWidget } from "./issue-widget-node"; +import { IIssueEmbedConfig } from "./types"; + +interface IssueWidgetExtensionProps { + issueEmbedConfig?: IIssueEmbedConfig; +} + +export const IssueWidgetExtension = ({ + issueEmbedConfig, +}: IssueWidgetExtensionProps) => IssueWidget.configure({ + issueEmbedConfig, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx new file mode 100644 index 000000000..79aabcdfa --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-card.tsx @@ -0,0 +1,89 @@ +// @ts-nocheck +import { useState, useEffect } from "react"; +import { NodeViewWrapper } from "@tiptap/react"; +import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui"; +import { Calendar, AlertTriangle } from "lucide-react"; + +const IssueWidgetCard = (props) => { + const [loading, setLoading] = useState(1); + const [issueDetails, setIssueDetails] = useState(); + + useEffect(() => { + props.issueEmbedConfig + .fetchIssue(props.node.attrs.entity_identifier) + .then((issue) => { + setIssueDetails(issue); + setLoading(0); + }) + .catch((error) => { + console.log(error); + setLoading(-1); + }); + }, []); + + const completeIssueEmbedAction = () => { + props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title); + }; + + return ( + + {loading == 0 ? ( +
+
+ {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} +
+

+ {issueDetails.name} +

+
+
+ +
+
+ + {issueDetails.assignee_details.map((assignee) => { + return ( + + ); + })} + +
+ {issueDetails.target_date && ( +
+ + {new Date(issueDetails.target_date).toLocaleDateString()} +
+ )} +
+
+ ) : loading == -1 ? ( +
+ + { + "This Issue embed is not found in any project. It can no longer be updated or accessed from here." + } +
+ ) : ( +
+ + +
+ + +
+
+
+ )} +
+ ); +}; + +export default IssueWidgetCard; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx new file mode 100644 index 000000000..014197184 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/issue-widget-node.tsx @@ -0,0 +1,68 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import IssueWidgetCard from "./issue-widget-card"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export const IssueWidget = Node.create({ + name: "issue-embed-component", + group: "block", + atom: true, + + addAttributes() { + return { + id: { + default: null, + }, + class: { + default: "w-[600px]", + }, + title: { + default: null, + }, + entity_name: { + default: null, + }, + entity_identifier: { + default: null, + }, + project_identifier: { + default: null, + }, + sequence_id: { + default: null, + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer((props: Object) => ( + + )); + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + getAttrs: (node: string | HTMLElement) => { + if (typeof node === "string") { + return null; + } + return { + id: node.getAttribute("id") || "", + title: node.getAttribute("title") || "", + entity_name: node.getAttribute("entity_name") || "", + entity_identifier: node.getAttribute("entity_identifier") || "", + project_identifier: node.getAttribute("project_identifier") || "", + sequence_id: node.getAttribute("sequence_id") || "", + }; + }, + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts new file mode 100644 index 000000000..9e633c0c8 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/IssueEmbedWidget/types.ts @@ -0,0 +1,9 @@ +export interface IEmbedConfig { + issueEmbedConfig: IIssueEmbedConfig; +} + +export interface IIssueEmbedConfig { + fetchIssue: (issueId: string) => Promise; + clickAction: (issueId: string, issueTitle: string) => void; + issues: Array; +} diff --git a/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx new file mode 100644 index 000000000..e8b58a2b8 --- /dev/null +++ b/packages/editor/document-editor/src/ui/hooks/use-editor-markings.tsx @@ -0,0 +1,44 @@ +import { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { IMarking } from ".."; + +export const useEditorMarkings = () => { + const [markings, setMarkings] = useState([]); + + const updateMarkings = (json: any) => { + const nodes = json.content as any[]; + const tempMarkings: IMarking[] = []; + let h1Sequence: number = 0; + let h2Sequence: number = 0; + let h3Sequence: number = 0; + if (nodes) { + nodes.forEach((node) => { + if ( + node.type === "heading" && + (node.attrs.level === 1 || + node.attrs.level === 2 || + node.attrs.level === 3) && + node.content + ) { + tempMarkings.push({ + type: "heading", + level: node.attrs.level, + text: node.content[0].text, + sequence: + node.attrs.level === 1 + ? ++h1Sequence + : node.attrs.level === 2 + ? ++h2Sequence + : ++h3Sequence, + }); + } + }); + } + setMarkings(tempMarkings); + }; + + return { + updateMarkings, + markings, + }; +}; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx new file mode 100644 index 000000000..f2ff77455 --- /dev/null +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -0,0 +1,196 @@ +"use client"; +import React, { useState } from "react"; +import { getEditorClassNames, useEditor } from "@plane/editor-core"; +import { DocumentEditorExtensions } from "./extensions"; +import { + IDuplicationConfig, + IPageArchiveConfig, + IPageLockConfig, +} from "./types/menu-actions"; +import { EditorHeader } from "./components/editor-header"; +import { useEditorMarkings } from "./hooks/use-editor-markings"; +import { SummarySideBar } from "./components/summary-side-bar"; +import { DocumentDetails } from "./types/editor-types"; +import { PageRenderer } from "./components/page-renderer"; +import { getMenuOptions } from "./utils/menu-options"; +import { useRouter } from "next/router"; +import { IEmbedConfig } from "./extensions/widgets/IssueEmbedWidget/types"; +import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types"; + +interface IDocumentEditor { + // document info + documentDetails: DocumentDetails; + value: string; + rerenderOnPropsChange: { + id: string; + description_html: string; + }; + + // file operations + uploadFile: UploadImage; + deleteFile: DeleteImage; + restoreFile: RestoreImage; + cancelUploadImage: () => any; + + // editor state managers + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; + customClassName?: string; + editorContentCustomClassNames?: string; + onChange: (json: any, html: string) => void; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; + setShouldShowAlert?: (showAlert: boolean) => void; + forwardedRef?: any; + updatePageTitle: (title: string) => Promise; + debouncedUpdatesEnabled?: boolean; + isSubmitting: "submitting" | "submitted" | "saved"; + + // embed configuration + duplicationConfig?: IDuplicationConfig; + pageLockConfig?: IPageLockConfig; + pageArchiveConfig?: IPageArchiveConfig; + embedConfig?: IEmbedConfig; +} +interface DocumentEditorProps extends IDocumentEditor { + forwardedRef?: React.Ref; +} + +interface EditorHandle { + clearEditor: () => void; + setEditorValue: (content: string) => void; +} + +export interface IMarking { + type: "heading"; + level: number; + text: string; + sequence: number; +} + +const DocumentEditor = ({ + documentDetails, + onChange, + debouncedUpdatesEnabled, + setIsSubmitting, + setShouldShowAlert, + editorContentCustomClassNames, + value, + uploadFile, + deleteFile, + restoreFile, + isSubmitting, + customClassName, + forwardedRef, + duplicationConfig, + pageLockConfig, + pageArchiveConfig, + embedConfig, + updatePageTitle, + cancelUploadImage, + onActionCompleteHandler, + rerenderOnPropsChange, +}: IDocumentEditor) => { + // const [alert, setAlert] = useState("") + const { markings, updateMarkings } = useEditorMarkings(); + const [sidePeekVisible, setSidePeekVisible] = useState(true); + const router = useRouter(); + + const editor = useEditor({ + onChange(json, html) { + updateMarkings(json); + onChange(json, html); + }, + onStart(json) { + updateMarkings(json); + }, + debouncedUpdatesEnabled, + restoreFile, + setIsSubmitting, + setShouldShowAlert, + value, + uploadFile, + deleteFile, + cancelUploadImage, + rerenderOnPropsChange, + forwardedRef, + extensions: DocumentEditorExtensions( + uploadFile, + embedConfig?.issueEmbedConfig, + setIsSubmitting, + ), + }); + + if (!editor) { + return null; + } + + const KanbanMenuOptions = getMenuOptions({ + editor: editor, + router: router, + duplicationConfig: duplicationConfig, + pageLockConfig: pageLockConfig, + pageArchiveConfig: pageArchiveConfig, + onActionCompleteHandler, + }); + + const editorClassNames = getEditorClassNames({ + noBorder: true, + borderOnFocus: false, + customClassName, + }); + + if (!editor) return null; + + return ( +
+ setSidePeekVisible(val)} + markings={markings} + uploadFile={uploadFile} + setIsSubmitting={setIsSubmitting} + isLocked={!pageLockConfig ? false : pageLockConfig.is_locked} + isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived} + archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} + documentDetails={documentDetails} + isSubmitting={isSubmitting} + /> +
+
+ +
+
+ +
+
+
+
+ ); +}; + +const DocumentEditorWithRef = React.forwardRef( + (props, ref) => , +); + +DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; + +export { DocumentEditor, DocumentEditorWithRef }; diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx new file mode 100644 index 000000000..a11f5f358 --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -0,0 +1,177 @@ +import { Editor } from "@tiptap/react"; +import { BoldIcon } from "lucide-react"; + +import { + BoldItem, + BulletListItem, + isCellSelection, + cn, + CodeItem, + ImageItem, + ItalicItem, + NumberedListItem, + QuoteItem, + StrikeThroughItem, + TableItem, + UnderLineItem, + HeadingOneItem, + HeadingTwoItem, + HeadingThreeItem, + findTableAncestor, +} from "@plane/editor-core"; +import { UploadImage } from "@plane/editor-types"; + +export interface BubbleMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: typeof BoldIcon; +} + +type EditorBubbleMenuProps = { + editor: Editor; + uploadFile: UploadImage; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; +}; + +export const FixedMenu = (props: EditorBubbleMenuProps) => { + const { editor, uploadFile, setIsSubmitting } = props; + + const basicMarkItems: BubbleMenuItem[] = [ + HeadingOneItem(editor), + HeadingTwoItem(editor), + HeadingThreeItem(editor), + BoldItem(editor), + ItalicItem(editor), + UnderLineItem(editor), + StrikeThroughItem(editor), + ]; + + const listItems: BubbleMenuItem[] = [ + BulletListItem(editor), + NumberedListItem(editor), + ]; + + const userActionItems: BubbleMenuItem[] = [ + QuoteItem(editor), + CodeItem(editor), + ]; + + function getComplexItems(): BubbleMenuItem[] { + const items: BubbleMenuItem[] = [TableItem(editor)]; + + if (shouldShowImageItem()) { + items.push(ImageItem(editor, uploadFile, setIsSubmitting)); + } + + return items; + } + + const complexItems: BubbleMenuItem[] = getComplexItems(); + + function shouldShowImageItem(): boolean { + if (typeof window !== "undefined") { + const selectionRange: any = window?.getSelection(); + const { selection } = props.editor.state; + + if (selectionRange.rangeCount !== 0) { + const range = selectionRange.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return false; + } + if (isCellSelection(selection)) { + return false; + } + } + return true; + } + return false; + } + + return ( +
+
+ {basicMarkItems.map((item) => ( + + ))} +
+
+ {listItems.map((item) => ( + + ))} +
+
+ {userActionItems.map((item) => ( + + ))} +
+
+ {complexItems.map((item) => ( + + ))} +
+
+ ); +}; diff --git a/web/components/ui/icon.tsx b/packages/editor/document-editor/src/ui/menu/icon.tsx similarity index 57% rename from web/components/ui/icon.tsx rename to packages/editor/document-editor/src/ui/menu/icon.tsx index 418186291..60878f9bf 100644 --- a/web/components/ui/icon.tsx +++ b/packages/editor/document-editor/src/ui/menu/icon.tsx @@ -6,5 +6,9 @@ type Props = { }; export const Icon: React.FC = ({ iconName, className = "" }) => ( - {iconName} + + {iconName} + ); diff --git a/packages/editor/document-editor/src/ui/menu/index.tsx b/packages/editor/document-editor/src/ui/menu/index.tsx new file mode 100644 index 000000000..1c411fabf --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/index.tsx @@ -0,0 +1 @@ +export { FixedMenu } from "./fixed-menu"; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx new file mode 100644 index 000000000..c6e8ef025 --- /dev/null +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -0,0 +1,144 @@ +import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { useRouter } from "next/router"; +import { useState, forwardRef, useEffect } from "react"; +import { EditorHeader } from "../components/editor-header"; +import { PageRenderer } from "../components/page-renderer"; +import { SummarySideBar } from "../components/summary-side-bar"; +import { IssueWidgetExtension } from "../extensions/widgets/IssueEmbedWidget"; +import { IEmbedConfig } from "../extensions/widgets/IssueEmbedWidget/types"; +import { useEditorMarkings } from "../hooks/use-editor-markings"; +import { DocumentDetails } from "../types/editor-types"; +import { + IPageArchiveConfig, + IPageLockConfig, + IDuplicationConfig, +} from "../types/menu-actions"; +import { getMenuOptions } from "../utils/menu-options"; + +interface IDocumentReadOnlyEditor { + value: string; + rerenderOnPropsChange?: { + id: string; + description_html: string; + }; + noBorder: boolean; + borderOnFocus: boolean; + customClassName: string; + documentDetails: DocumentDetails; + pageLockConfig?: IPageLockConfig; + pageArchiveConfig?: IPageArchiveConfig; + pageDuplicationConfig?: IDuplicationConfig; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; + embedConfig?: IEmbedConfig; +} + +interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { + forwardedRef?: React.Ref; +} + +interface EditorHandle { + clearEditor: () => void; + setEditorValue: (content: string) => void; +} + +const DocumentReadOnlyEditor = ({ + noBorder, + borderOnFocus, + customClassName, + value, + documentDetails, + forwardedRef, + pageDuplicationConfig, + pageLockConfig, + pageArchiveConfig, + embedConfig, + rerenderOnPropsChange, + onActionCompleteHandler, +}: DocumentReadOnlyEditorProps) => { + const router = useRouter(); + const [sidePeekVisible, setSidePeekVisible] = useState(true); + const { markings, updateMarkings } = useEditorMarkings(); + + const editor = useReadOnlyEditor({ + value, + forwardedRef, + rerenderOnPropsChange, + extensions: [ + IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig }), + ], + }); + + useEffect(() => { + if (editor) { + updateMarkings(editor.getJSON()); + } + }, [editor]); + + if (!editor) { + return null; + } + + const editorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, + }); + + const KanbanMenuOptions = getMenuOptions({ + editor: editor, + router: router, + pageArchiveConfig: pageArchiveConfig, + pageLockConfig: pageLockConfig, + duplicationConfig: pageDuplicationConfig, + onActionCompleteHandler, + }); + + return ( +
+ +
+
+ +
+
+ Promise.resolve()} + readonly={true} + editor={editor} + editorClassNames={editorClassNames} + documentDetails={documentDetails} + /> +
+
+
+
+ ); +}; + +const DocumentReadOnlyEditorWithRef = forwardRef< + EditorHandle, + IDocumentReadOnlyEditor +>((props, ref) => ); + +DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef"; + +export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }; diff --git a/space/components/ui/tooltip.tsx b/packages/editor/document-editor/src/ui/tooltip.tsx similarity index 67% rename from space/components/ui/tooltip.tsx rename to packages/editor/document-editor/src/ui/tooltip.tsx index 994c0f32a..d82ed9600 100644 --- a/space/components/ui/tooltip.tsx +++ b/packages/editor/document-editor/src/ui/tooltip.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import * as React from "react"; // next-themes import { useTheme } from "next-themes"; @@ -50,12 +50,18 @@ export const Tooltip: React.FC = ({ hoverCloseDelay={closeDelay} content={
{tooltipHeading && ( -
+
{tooltipHeading}
)} @@ -63,8 +69,16 @@ export const Tooltip: React.FC = ({
} position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => - React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) + renderTarget={({ + isOpen: isTooltipOpen, + ref: eleReference, + ...tooltipProps + }) => + React.cloneElement(children, { + ref: eleReference, + ...tooltipProps, + ...children.props, + }) } /> ); diff --git a/packages/editor/document-editor/src/ui/types/editor-types.ts b/packages/editor/document-editor/src/ui/types/editor-types.ts new file mode 100644 index 000000000..10e9b16b6 --- /dev/null +++ b/packages/editor/document-editor/src/ui/types/editor-types.ts @@ -0,0 +1,7 @@ +export interface DocumentDetails { + title: string; + created_by: string; + created_on: Date; + last_updated_by: string; + last_updated_at: Date; +} diff --git a/packages/editor/document-editor/src/ui/types/menu-actions.d.ts b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts new file mode 100644 index 000000000..87e848be7 --- /dev/null +++ b/packages/editor/document-editor/src/ui/types/menu-actions.d.ts @@ -0,0 +1,13 @@ +export interface IDuplicationConfig { + action: () => Promise; +} +export interface IPageLockConfig { + is_locked: boolean; + action: () => Promise; + locked_by?: string; +} +export interface IPageArchiveConfig { + is_archived: boolean; + archived_at?: Date; + action: () => Promise; +} diff --git a/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts new file mode 100644 index 000000000..248f439e3 --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/editor-summary-utils.ts @@ -0,0 +1,34 @@ +import { Editor } from "@tiptap/react"; +import { IMarking } from ".."; + +function findNthH1(editor: Editor, n: number, level: number): number { + let count = 0; + let pos = 0; + editor.state.doc.descendants((node, position) => { + if (node.type.name === "heading" && node.attrs.level === level) { + count++; + if (count === n) { + pos = position; + return false; + } + } + }); + return pos; +} + +function scrollToNode(editor: Editor, pos: number): void { + const headingNode = editor.state.doc.nodeAt(pos); + if (headingNode) { + const headingDOM = editor.view.nodeDOM(pos); + if (headingDOM instanceof HTMLElement) { + headingDOM.scrollIntoView({ behavior: "smooth" }); + } + } +} + +export function scrollSummary(editor: Editor, marking: IMarking) { + if (editor) { + const pos = findNthH1(editor, marking.sequence, marking.level); + scrollToNode(editor, pos); + } +} diff --git a/packages/editor/document-editor/src/ui/utils/menu-actions.ts b/packages/editor/document-editor/src/ui/utils/menu-actions.ts new file mode 100644 index 000000000..24eda5a05 --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/menu-actions.ts @@ -0,0 +1,12 @@ +import { Editor } from "@tiptap/core"; + +export const copyMarkdownToClipboard = (editor: Editor | null) => { + const markdownOutput = editor?.storage.markdown.getMarkdown(); + navigator.clipboard.writeText(markdownOutput); +}; + +export const CopyPageLink = () => { + if (window) { + navigator.clipboard.writeText(window.location.toString()); + } +}; diff --git a/packages/editor/document-editor/src/ui/utils/menu-options.ts b/packages/editor/document-editor/src/ui/utils/menu-options.ts new file mode 100644 index 000000000..5df467ddf --- /dev/null +++ b/packages/editor/document-editor/src/ui/utils/menu-options.ts @@ -0,0 +1,168 @@ +import { Editor } from "@tiptap/react"; +import { + Archive, + ArchiveIcon, + ArchiveRestoreIcon, + ClipboardIcon, + Copy, + Link, + Lock, + Unlock, + XCircle, +} from "lucide-react"; +import { NextRouter } from "next/router"; +import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"; +import { + IDuplicationConfig, + IPageArchiveConfig, + IPageLockConfig, +} from "../types/menu-actions"; +import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"; + +export interface MenuOptionsProps { + editor: Editor; + router: NextRouter; + duplicationConfig?: IDuplicationConfig; + pageLockConfig?: IPageLockConfig; + pageArchiveConfig?: IPageArchiveConfig; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; +} + +export const getMenuOptions = ({ + editor, + router, + duplicationConfig, + pageLockConfig, + pageArchiveConfig, + onActionCompleteHandler, +}: MenuOptionsProps) => { + const KanbanMenuOptions: IVerticalDropdownItemProps[] = [ + { + key: 1, + type: "copy_markdown", + Icon: ClipboardIcon, + action: () => { + onActionCompleteHandler({ + title: "Markdown Copied", + message: "Page Copied as Markdown", + type: "success", + }); + copyMarkdownToClipboard(editor); + }, + label: "Copy markdown", + }, + // { + // key: 2, + // type: "close_page", + // Icon: XCircle, + // action: () => router.back(), + // label: "Close page", + // }, + { + key: 3, + type: "copy_page_link", + Icon: Link, + action: () => { + onActionCompleteHandler({ + title: "Link Copied", + message: "Link to the page has been copied to clipboard", + type: "success", + }); + CopyPageLink(); + }, + label: "Copy page link", + }, + ]; + + // If duplicateConfig is given, page duplication will be allowed + if (duplicationConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + type: "duplicate_page", + Icon: Copy, + action: () => { + duplicationConfig + .action() + .then(() => { + onActionCompleteHandler({ + title: "Page Copied", + message: + "Page has been copied as 'Copy of' followed by page title", + type: "success", + }); + }) + .catch(() => { + onActionCompleteHandler({ + title: "Copy Failed", + message: "Sorry, page cannot be copied, please try again later.", + type: "error", + }); + }); + }, + label: "Make a copy", + }); + } + // If Lock Configuration is given then, lock page option will be available in the kanban menu + if (pageLockConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + type: pageLockConfig.is_locked ? "unlock_page" : "lock_page", + Icon: pageLockConfig.is_locked ? Unlock : Lock, + label: pageLockConfig.is_locked ? "Unlock page" : "Lock page", + action: () => { + const state = pageLockConfig.is_locked ? "Unlocked" : "Locked"; + pageLockConfig + .action() + .then(() => { + onActionCompleteHandler({ + title: `Page ${state}`, + message: `Page has been ${state}, no one will be able to change the state of lock except you.`, + type: "success", + }); + }) + .catch(() => { + onActionCompleteHandler({ + title: `Page cannot be ${state}`, + message: `Sorry, page cannot be ${state}, please try again later`, + type: "error", + }); + }); + }, + }); + } + + // Archiving will be visible in the menu bar config once the pageArchiveConfig is given. + if (pageArchiveConfig) { + KanbanMenuOptions.push({ + key: KanbanMenuOptions.length++, + type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page", + Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive, + label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page", + action: () => { + const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived"; + pageArchiveConfig + .action() + .then(() => { + onActionCompleteHandler({ + title: `Page ${state}`, + message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`, + type: "success", + }); + }) + .catch(() => { + onActionCompleteHandler({ + title: `Page cannot be ${state}`, + message: `Sorry, page cannot be ${state}, please try again later.`, + type: "success", + }); + }); + }, + }); + } + + return KanbanMenuOptions; +}; diff --git a/packages/editor/document-editor/tailwind.config.js b/packages/editor/document-editor/tailwind.config.js new file mode 100644 index 000000000..f32063158 --- /dev/null +++ b/packages/editor/document-editor/tailwind.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + // prefix ui lib classes to avoid conflicting with the app + ...sharedConfig, +}; diff --git a/packages/editor/document-editor/tsconfig.json b/packages/editor/document-editor/tsconfig.json new file mode 100644 index 000000000..57d0e9a74 --- /dev/null +++ b/packages/editor/document-editor/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/editor/document-editor/tsup.config.ts b/packages/editor/document-editor/tsup.config.ts new file mode 100644 index 000000000..5e89e04af --- /dev/null +++ b/packages/editor/document-editor/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: false, + external: ["react"], + injectStyle: true, + ...options, +})); diff --git a/packages/editor/extensions/Readme.md b/packages/editor/extensions/Readme.md new file mode 100644 index 000000000..39aca1226 --- /dev/null +++ b/packages/editor/extensions/Readme.md @@ -0,0 +1,97 @@ +# @plane/editor-extensions + +## Description + +The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc. + +## Key Features + +- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code. + + `LiteTextEditor` & `LiteTextEditorWithRef` + +- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref) + `LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef` + +## LiteTextEditor + +| Prop | Type | Description | +| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | +| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | +| `value` | `html string` | The initial content of the editor. | +| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | +| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | +| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | +| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | +| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | + +### Usage + +1. Here is an example of how to use the `RichTextEditor` component + +```tsx + { + onChange(comment_html); + }} +/> +``` + +2. Example of how to use the `LiteTextEditorWithRef` component + +```tsx +const editorRef = useRef(null); + +// can use it to set the editor's value +editorRef.current?.setEditorValue(`${watch("description_html")}`); + +// can use it to clear the editor +editorRef?.current?.clearEditor(); + +return ( + { + onChange(comment_html); + }} + /> +); +``` + +## LiteReadOnlyEditor + +| Prop | Type | Description | +| ------------------------------- | ------------- | --------------------------------------------------------------------- | +| `value` | `html string` | The initial content of the editor. | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | + +### Usage + +Here is an example of how to use the `RichReadOnlyEditor` component + +```tsx + +``` diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json new file mode 100644 index 000000000..b106b8517 --- /dev/null +++ b/packages/editor/extensions/package.json @@ -0,0 +1,60 @@ +{ + "name": "@plane/editor-extensions", + "version": "0.1.0", + "description": "Package that powers Plane's Editor with extensions", + "private": true, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsup --minify", + "dev": "tsup --watch", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "next": "12.3.2", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" + }, + "dependencies": { + "@tiptap/react": "^2.1.7", + "@tiptap/core": "^2.1.7", + "@tiptap/suggestion": "^2.0.4", + "@plane/editor-types": "*", + "@plane/editor-core": "*", + "eslint": "8.36.0", + "eslint-config-next": "13.2.4", + "lucide-react": "^0.244.0", + "tippy.js": "^6.3.7", + "@tiptap/pm": "^2.1.7" + }, + "devDependencies": { + "@types/node": "18.15.3", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "eslint": "^7.32.0", + "postcss": "^8.4.29", + "tailwind-config-custom": "*", + "tsconfig": "*", + "tsup": "^7.2.0", + "typescript": "4.9.5" + }, + "keywords": [ + "editor", + "rich-text", + "markdown", + "nextjs", + "react" + ] +} \ No newline at end of file diff --git a/packages/editor/extensions/postcss.config.js b/packages/editor/extensions/postcss.config.js new file mode 100644 index 000000000..07aa434b2 --- /dev/null +++ b/packages/editor/extensions/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx new file mode 100644 index 000000000..5bf35cb2b --- /dev/null +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -0,0 +1,256 @@ +import { Extension } from "@tiptap/core"; + +import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; +// @ts-ignore +import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; + +function createDragHandleElement(): HTMLElement { + const dragHandleElement = document.createElement("div"); + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ""; + dragHandleElement.classList.add("drag-handle"); + + const dragHandleContainer = document.createElement("div"); + dragHandleContainer.classList.add("drag-handle-container"); + dragHandleElement.appendChild(dragHandleContainer); + + const dotsContainer = document.createElement("div"); + dotsContainer.classList.add("drag-handle-dots"); + + for (let i = 0; i < 6; i++) { + const spanElement = document.createElement("span"); + spanElement.classList.add("drag-handle-dot"); + dotsContainer.appendChild(spanElement); + } + + dragHandleContainer.appendChild(dotsContainer); + + return dragHandleElement; +} + +export interface DragHandleOptions { + dragHandleWidth: number; +} + +function absoluteRect(node: Element) { + const data = node.getBoundingClientRect(); + + return { + top: data.top, + left: data.left, + width: data.width, + }; +} + +function nodeDOMAtCoords(coords: { x: number; y: number }) { + return document + .elementsFromPoint(coords.x, coords.y) + .find((elem: Element) => { + return ( + elem.parentElement?.matches?.(".ProseMirror") || + elem.matches( + [ + "li", + "p:not(:first-child)", + "pre", + "blockquote", + "h1, h2, h3", + "[data-type=horizontalRule]", + ".tableWrapper", + ].join(", "), + ) + ); + }); +} + +function nodePosAtDOM(node: Element, view: EditorView) { + const boundingRect = node.getBoundingClientRect(); + + if (node.nodeName === "IMG") { + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.pos; + } + + if (node.nodeName === "PRE") { + return ( + view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.pos! - 1 + ); + } + + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.inside; +} + +function DragHandle(options: DragHandleOptions) { + function handleDragStart(event: DragEvent, view: EditorView) { + view.focus(); + + if (!event.dataTransfer) return; + + const node = nodeDOMAtCoords({ + x: event.clientX + options.dragHandleWidth + 50, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + const nodePos = nodePosAtDOM(node, view); + if (nodePos === null || nodePos === undefined || nodePos < 0) return; + + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)), + ); + + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); + + event.dataTransfer.clearData(); + event.dataTransfer.setData("text/html", dom.innerHTML); + event.dataTransfer.setData("text/plain", text); + event.dataTransfer.effectAllowed = "copyMove"; + + event.dataTransfer.setDragImage(node, 0, 0); + + view.dragging = { slice, move: event.ctrlKey }; + } + + function handleClick(event: MouseEvent, view: EditorView) { + view.focus(); + + view.dom.classList.remove("dragging"); + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + const nodePos = nodePosAtDOM(node, view); + + if (nodePos === null || nodePos === undefined || nodePos < 0) return; + + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)), + ); + } + + let dragHandleElement: HTMLElement | null = null; + + function hideDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.add("hidden"); + } + } + + function showDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.remove("hidden"); + } + } + + return new Plugin({ + key: new PluginKey("dragHandle"), + view: (view) => { + dragHandleElement = createDragHandleElement(); + dragHandleElement.addEventListener("dragstart", (e) => { + handleDragStart(e, view); + }); + dragHandleElement.addEventListener("click", (e) => { + handleClick(e, view); + }); + + dragHandleElement.addEventListener("dragstart", (e) => { + handleDragStart(e, view); + }); + dragHandleElement.addEventListener("click", (e) => { + handleClick(e, view); + }); + + hideDragHandle(); + + view?.dom?.parentElement?.appendChild(dragHandleElement); + + return { + destroy: () => { + dragHandleElement?.remove?.(); + dragHandleElement = null; + }, + }; + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + if (!view.editable) { + return; + } + + const node = nodeDOMAtCoords({ + x: event.clientX + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) { + hideDragHandle(); + return; + } + + const compStyle = window.getComputedStyle(node); + const lineHeight = parseInt(compStyle.lineHeight, 10); + const paddingTop = parseInt(compStyle.paddingTop, 10); + + const rect = absoluteRect(node); + + rect.top += (lineHeight - 24) / 2; + rect.top += paddingTop; + // Li markers + if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + rect.left -= options.dragHandleWidth; + } + rect.width = options.dragHandleWidth; + + if (!dragHandleElement) return; + + dragHandleElement.style.left = `${rect.left - rect.width}px`; + dragHandleElement.style.top = `${rect.top + 3}px`; + showDragHandle(); + }, + keydown: () => { + hideDragHandle(); + }, + wheel: () => { + hideDragHandle(); + }, + // dragging className is used for CSS + dragstart: (view) => { + view.dom.classList.add("dragging"); + }, + drop: (view) => { + view.dom.classList.remove("dragging"); + }, + dragend: (view) => { + view.dom.classList.remove("dragging"); + }, + }, + }, + }); +} + +export const DragAndDrop = Extension.create({ + name: "dragAndDrop", + + addProseMirrorPlugins() { + return [ + DragHandle({ + dragHandleWidth: 24, + }), + ]; + }, +}); diff --git a/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx similarity index 56% rename from packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx rename to packages/editor/extensions/src/extensions/slash-commands.tsx index bab13304a..8ca132405 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -10,6 +10,7 @@ import { Editor, Range, Extension } from "@tiptap/core"; import Suggestion from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; import tippy from "tippy.js"; +import type { UploadImage, ISlashCommandItem, CommandProps } from "@plane/editor-types"; import { Heading1, Heading2, @@ -24,7 +25,6 @@ import { ImageIcon, Table, } from "lucide-react"; -import { UploadImage } from "../"; import { cn, insertTableCommand, @@ -44,11 +44,6 @@ interface CommandItemProps { icon: ReactNode; } -interface CommandProps { - editor: Editor; - range: Range; -} - const Command = Extension.create({ name: "slash-command", addOptions() { @@ -88,132 +83,146 @@ const getSuggestionItems = setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void, + additonalOptions?: Array ) => - ({ query }: { query: string }) => - [ - { - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor - .chain() - .focus() - .deleteRange(range) - .toggleNode("paragraph", "paragraph") - .run(); + ({ query }: { query: string }) => { + let slashCommands: ISlashCommandItem[] = [ + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .run(); + }, }, - }, - { - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingOne(editor, range); + }, }, - }, - { - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingTwo(editor, range); + }, }, - }, - { - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingThree(editor, range); + }, }, - }, - { - title: "To-do List", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range); + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleTaskList(editor, range); + }, }, - }, - { - title: "Bullet List", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleBulletList(editor, range); + }, }, - }, - { - title: "Divider", - description: "Visually divide blocks", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + { + title: "Divider", + description: "Visually divide blocks", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }: CommandProps) => { + // @ts-expect-error I have to move this to the core + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }, }, - }, - { - title: "Table", - description: "Create a Table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }: CommandProps) => { + insertTableCommand(editor, range); + }, }, - }, - { - title: "Numbered List", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleOrderedList(editor, range); + }, }, - }, - { - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => - toggleBlockquote(editor, range), - }, - { - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - title: "Image", - description: "Upload an image from your computer.", - searchTerms: ["photo", "picture", "media"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, setIsSubmitting, range); + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }: CommandProps) => + toggleBlockquote(editor, range), }, - }, - ].filter((item) => { - if (typeof query === "string" && query.length > 0) { - const search = query.toLowerCase(); - return ( - item.title.toLowerCase().includes(search) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && - item.searchTerms.some((term: string) => term.includes(search))) - ); + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }: CommandProps) => + // @ts-expect-error I have to move this to the core + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: "Image", + description: "Upload an image from your computer.", + searchTerms: ["photo", "picture", "media"], + icon: , + command: ({ editor, range }: CommandProps) => { + insertImageCommand(editor, uploadFile, setIsSubmitting, range); + }, + }, + ] + + if (additonalOptions) { + additonalOptions.map(item => { + slashCommands.push(item) + }) } - return true; - }); + + slashCommands = slashCommands.filter((item) => { + if (typeof query === "string" && query.length > 0) { + const search = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(search) || + item.description.toLowerCase().includes(search) || + (item.searchTerms && + item.searchTerms.some((term: string) => term.includes(search))) + ); + } + return true; + }) + + return slashCommands + }; export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { const containerHeight = container.offsetHeight; @@ -374,12 +383,11 @@ export const SlashCommand = ( setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void, + additonalOptions?: Array, ) => Command.configure({ suggestion: { - items: getSuggestionItems(uploadFile, setIsSubmitting), + items: getSuggestionItems(uploadFile, setIsSubmitting, additonalOptions), render: renderItems, }, }); - -export default SlashCommand; diff --git a/packages/editor/extensions/src/index.ts b/packages/editor/extensions/src/index.ts new file mode 100644 index 000000000..76461c2e6 --- /dev/null +++ b/packages/editor/extensions/src/index.ts @@ -0,0 +1,2 @@ +export { SlashCommand } from "./extensions/slash-commands"; +export { DragAndDrop } from "./extensions/drag-drop"; diff --git a/packages/editor/extensions/tailwind.config.js b/packages/editor/extensions/tailwind.config.js new file mode 100644 index 000000000..f32063158 --- /dev/null +++ b/packages/editor/extensions/tailwind.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + // prefix ui lib classes to avoid conflicting with the app + ...sharedConfig, +}; diff --git a/packages/editor/extensions/tsconfig.json b/packages/editor/extensions/tsconfig.json new file mode 100644 index 000000000..57d0e9a74 --- /dev/null +++ b/packages/editor/extensions/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/editor/extensions/tsup.config.ts b/packages/editor/extensions/tsup.config.ts new file mode 100644 index 000000000..5e89e04af --- /dev/null +++ b/packages/editor/extensions/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: false, + external: ["react"], + injectStyle: true, + ...options, +})); diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 52f27fb29..00efc08ea 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.0.1", + "version": "0.1.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", @@ -17,9 +17,10 @@ } }, "scripts": { - "build": "tsup", + "build": "tsup --minify", "dev": "tsup --watch", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "peerDependencies": { "next": "12.3.2", @@ -30,27 +31,16 @@ "dependencies": { "@plane/editor-core": "*", "@plane/ui": "*", - "@tiptap/extension-list-item": "^2.1.11", - "class-variance-authority": "^0.7.0", - "clsx": "^1.2.1", - "eslint": "8.36.0", - "eslint-config-next": "13.2.4", - "eventsource-parser": "^0.1.0", - "lowlight": "^2.9.0", - "lucide-react": "^0.244.0", - "react-markdown": "^8.0.7", - "tailwind-merge": "^1.14.0", - "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.2", - "use-debounce": "^9.0.4" + "@plane/editor-types": "*" }, "devDependencies": { "@types/node": "18.15.3", - "@types/react": "^18.2.35", - "@types/react-dom": "^18.2.14", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", "eslint": "^7.32.0", "postcss": "^8.4.29", "tailwind-config-custom": "*", + "eslint-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", "typescript": "4.9.5" @@ -62,4 +52,4 @@ "nextjs", "react" ] -} +} \ No newline at end of file diff --git a/packages/editor/lite-text-editor/src/index.ts b/packages/editor/lite-text-editor/src/index.ts index ba916e666..49001e055 100644 --- a/packages/editor/lite-text-editor/src/index.ts +++ b/packages/editor/lite-text-editor/src/index.ts @@ -1,3 +1,6 @@ export { LiteTextEditor, LiteTextEditorWithRef } from "./ui"; export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only"; -export type { IMentionSuggestion, IMentionHighlight } from "./ui"; +export type { + IMentionSuggestion, + IMentionHighlight, +} from "@plane/editor-types"; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index e7decbcac..b0cf3ebbb 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -7,24 +7,19 @@ import { } from "@plane/editor-core"; import { FixedMenu } from "./menus/fixed-menu"; import { LiteTextEditorExtensions } from "./extensions"; - -export type UploadImage = (file: File) => Promise; -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -export type IMentionSuggestion = { - id: string; - type: string; - avatar: string; - title: string; - subtitle: string; - redirect_uri: string; -}; - -export type IMentionHighlight = string; +import { + UploadImage, + DeleteImage, + IMentionSuggestion, + RestoreImage, +} from "@plane/editor-types"; interface ILiteTextEditor { value: string; uploadFile: UploadImage; deleteFile: DeleteImage; + restoreFile: RestoreImage; + noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; @@ -73,6 +68,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { value, uploadFile, deleteFile, + restoreFile, noBorder, borderOnFocus, customClassName, @@ -93,6 +89,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { value, uploadFile, deleteFile, + restoreFile, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), mentionHighlights, diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx index a4fb0479c..2f727936c 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx @@ -1,12 +1,13 @@ import { Editor } from "@tiptap/react"; -import { BoldIcon } from "lucide-react"; import { BoldItem, BulletListItem, cn, CodeItem, + findTableAncestor, ImageItem, + isCellSelection, ItalicItem, NumberedListItem, QuoteItem, @@ -15,13 +16,20 @@ import { UnderLineItem, } from "@plane/editor-core"; import { Tooltip } from "@plane/ui"; -import { UploadImage } from "../../"; +import type { SVGProps } from "react"; +import { UploadImage } from "@plane/editor-types"; +interface LucideProps extends Partial> { + size?: string | number; + absoluteStrokeWidth?: boolean; +} + +type LucideIcon = (props: LucideProps) => JSX.Element; export interface BubbleMenuItem { name: string; isActive: () => boolean; command: () => void; - icon: typeof BoldIcon; + icon: LucideIcon; } type EditorBubbleMenuProps = { @@ -46,14 +54,14 @@ type EditorBubbleMenuProps = { }; export const FixedMenu = (props: EditorBubbleMenuProps) => { - const basicMarkItems: BubbleMenuItem[] = [ + const basicTextFormattingItems: BubbleMenuItem[] = [ BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor), ]; - const listItems: BubbleMenuItem[] = [ + const listFormattingItems: BubbleMenuItem[] = [ BulletListItem(props.editor), NumberedListItem(props.editor), ]; @@ -63,10 +71,38 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { CodeItem(props.editor), ]; - const complexItems: BubbleMenuItem[] = [ - TableItem(props.editor), - ImageItem(props.editor, props.uploadFile, props.setIsSubmitting), - ]; + function getComplexItems(): BubbleMenuItem[] { + const items: BubbleMenuItem[] = [TableItem(props.editor)]; + + if (shouldShowImageItem()) { + items.push( + ImageItem(props.editor, props.uploadFile, props.setIsSubmitting), + ); + } + + return items; + } + + const complexItems: BubbleMenuItem[] = getComplexItems(); + + function shouldShowImageItem(): boolean { + if (typeof window !== "undefined") { + const selectionRange: any = window?.getSelection(); + const { selection } = props.editor.state; + + if (selectionRange.rangeCount !== 0) { + const range = selectionRange.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return false; + } + if (isCellSelection(selection)) { + return false; + } + } + return true; + } + return false; + } const handleAccessChange = (accessKey: string) => { props.commentAccessSpecifier?.onAccessChange(accessKey); @@ -103,7 +139,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
- {basicMarkItems.map((item, index) => ( + {basicTextFormattingItems.map((item, index) => ( {item.name}} @@ -130,7 +166,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { ))}
- {listItems.map((item, index) => ( + {listFormattingItems.map((item, index) => ( {item.name}} diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index db793261c..86ada4668 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.0.1", + "version": "0.1.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -17,12 +17,12 @@ } }, "scripts": { - "build": "tsup", + "build": "tsup --minify", "dev": "tsup --watch", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "peerDependencies": { - "@tiptap/core": "^2.1.11", "next": "12.3.2", "next-themes": "^0.2.1", "react": "^18.2.0", @@ -30,20 +30,16 @@ }, "dependencies": { "@plane/editor-core": "*", - "@tiptap/extension-code-block-lowlight": "^2.1.11", - "@tiptap/extension-horizontal-rule": "^2.1.11", + "@tiptap/core": "^2.1.11", + "@plane/editor-types": "*", + "@plane/editor-extensions": "*", "@tiptap/extension-placeholder": "^2.1.11", - "@tiptap/suggestion": "^2.1.7", - "class-variance-authority": "^0.7.0", - "clsx": "^1.2.1", - "highlight.js": "^11.8.0", - "lowlight": "^3.0.0", "lucide-react": "^0.244.0" }, "devDependencies": { "@types/node": "18.15.3", - "@types/react": "^18.2.35", - "@types/react-dom": "^18.2.14", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", "eslint": "^7.32.0", "postcss": "^8.4.29", "react": "^18.2.0", @@ -59,4 +55,4 @@ "nextjs", "react" ] -} +} \ No newline at end of file diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts index 9ea7f9a39..e9f536d9a 100644 --- a/packages/editor/rich-text-editor/src/index.ts +++ b/packages/editor/rich-text-editor/src/index.ts @@ -1,5 +1,7 @@ -import "./styles/github-dark.css"; - export { RichTextEditor, RichTextEditorWithRef } from "./ui"; export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only"; -export type { IMentionSuggestion, IMentionHighlight } from "./ui"; +export type { RichTextEditorProps, IRichTextEditor } from "./ui"; +export type { + IMentionHighlight, + IMentionSuggestion, +} from "@plane/editor-types"; diff --git a/packages/editor/rich-text-editor/src/styles/github-dark.css b/packages/editor/rich-text-editor/src/styles/github-dark.css deleted file mode 100644 index 20a7f4e66..000000000 --- a/packages/editor/rich-text-editor/src/styles/github-dark.css +++ /dev/null @@ -1,2 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px} -.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c} diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index a28982da3..0464034cb 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,50 +1,17 @@ -import HorizontalRule from "@tiptap/extension-horizontal-rule"; +import { SlashCommand } from "@plane/editor-extensions"; import Placeholder from "@tiptap/extension-placeholder"; -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { common, createLowlight } from "lowlight"; -import { InputRule } from "@tiptap/core"; - -import ts from "highlight.js/lib/languages/typescript"; - -import SlashCommand from "./slash-command"; -import { UploadImage } from "../"; - -const lowlight = createLowlight(common); -lowlight.register("ts", ts); +import { DragAndDrop } from "@plane/editor-extensions"; +import { UploadImage } from "@plane/editor-types"; export const RichTextEditorExtensions = ( uploadFile: UploadImage, setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void, + dragDropEnabled?: boolean, ) => [ - HorizontalRule.extend({ - addInputRules() { - return [ - new InputRule({ - find: /^(?:---|—-|___\s|\*\*\*\s)$/, - handler: ({ state, range, commands }) => { - commands.splitBlock(); - - const attributes = {}; - const { tr } = state; - const start = range.from; - const end = range.to; - // @ts-ignore - tr.replaceWith(start - 1, end, this.type.create(attributes)); - }, - }), - ]; - }, - }).configure({ - HTMLAttributes: { - class: "mb-6 border-t border-custom-border-300", - }, - }), SlashCommand(uploadFile, setIsSubmitting), - CodeBlockLowlight.configure({ - lowlight, - }), + dragDropEnabled === true && DragAndDrop, Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { @@ -53,7 +20,9 @@ export const RichTextEditorExtensions = ( if (node.type.name === "image" || node.type.name === "table") { return ""; } - + if (node.type.name === "codeBlock") { + return "Type in your code here..."; + } return "Press '/' for commands..."; }, includeChildren: true, diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 2e98a72aa..653bbfc9a 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -8,28 +8,26 @@ import { } from "@plane/editor-core"; import { EditorBubbleMenu } from "./menus/bubble-menu"; import { RichTextEditorExtensions } from "./extensions"; +import { + DeleteImage, + IMentionSuggestion, + RestoreImage, + UploadImage, +} from "@plane/editor-types"; -export type UploadImage = (file: File) => Promise; -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; - -export type IMentionSuggestion = { - id: string; - type: string; - avatar: string; - title: string; - subtitle: string; - redirect_uri: string; -}; - -export type IMentionHighlight = string; - -interface IRichTextEditor { +export type IRichTextEditor = { value: string; + dragDropEnabled?: boolean; uploadFile: UploadImage; + restoreFile: RestoreImage; deleteFile: DeleteImage; noBorder?: boolean; borderOnFocus?: boolean; cancelUploadImage?: () => any; + rerenderOnPropsChange?: { + id: string; + description_html: string; + }; customClassName?: string; editorContentCustomClassNames?: string; onChange?: (json: any, html: string) => void; @@ -41,9 +39,9 @@ interface IRichTextEditor { debouncedUpdatesEnabled?: boolean; mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; -} +}; -interface RichTextEditorProps extends IRichTextEditor { +export interface RichTextEditorProps extends IRichTextEditor { forwardedRef?: React.Ref; } @@ -54,6 +52,7 @@ interface EditorHandle { const RichTextEditor = ({ onChange, + dragDropEnabled, debouncedUpdatesEnabled, setIsSubmitting, setShouldShowAlert, @@ -65,8 +64,10 @@ const RichTextEditor = ({ cancelUploadImage, borderOnFocus, customClassName, + restoreFile, forwardedRef, mentionHighlights, + rerenderOnPropsChange, mentionSuggestions, }: RichTextEditorProps) => { const editor = useEditor({ @@ -78,8 +79,14 @@ const RichTextEditor = ({ uploadFile, cancelUploadImage, deleteFile, + restoreFile, forwardedRef, - extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting), + rerenderOnPropsChange, + extensions: RichTextEditorExtensions( + uploadFile, + setIsSubmitting, + dragDropEnabled, + ), mentionHighlights, mentionSuggestions, }); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index f9d830599..a6b90bdde 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -38,21 +38,8 @@ export const EditorBubbleMenu: FC = (props: any) => { const { selection } = state; const { empty } = selection; - const hasEditorFocus = view.hasFocus(); - - // if (typeof window !== "undefined") { - // const selection: any = window?.getSelection(); - // if (selection.rangeCount !== 0) { - // const range = selection.getRangeAt(0); - // if (findTableAncestor(range.startContainer)) { - // console.log("table"); - // return false; - // } - // } - // } if ( - !hasEditorFocus || empty || !editor.isEditable || editor.isActive("image") || diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx index 965e7a42e..7681fbe5b 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx @@ -1,12 +1,12 @@ import { BulletListItem, cn, - CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, + CodeItem, TodoListItem, } from "@plane/editor-core"; import { Editor } from "@tiptap/react"; diff --git a/packages/editor/types/Readme.md b/packages/editor/types/Readme.md new file mode 100644 index 000000000..39aca1226 --- /dev/null +++ b/packages/editor/types/Readme.md @@ -0,0 +1,97 @@ +# @plane/editor-extensions + +## Description + +The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc. + +## Key Features + +- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code. + + `LiteTextEditor` & `LiteTextEditorWithRef` + +- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref) + `LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef` + +## LiteTextEditor + +| Prop | Type | Description | +| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | +| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | +| `value` | `html string` | The initial content of the editor. | +| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | +| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | +| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | +| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | +| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | + +### Usage + +1. Here is an example of how to use the `RichTextEditor` component + +```tsx + { + onChange(comment_html); + }} +/> +``` + +2. Example of how to use the `LiteTextEditorWithRef` component + +```tsx +const editorRef = useRef(null); + +// can use it to set the editor's value +editorRef.current?.setEditorValue(`${watch("description_html")}`); + +// can use it to clear the editor +editorRef?.current?.clearEditor(); + +return ( + { + onChange(comment_html); + }} + /> +); +``` + +## LiteReadOnlyEditor + +| Prop | Type | Description | +| ------------------------------- | ------------- | --------------------------------------------------------------------- | +| `value` | `html string` | The initial content of the editor. | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | + +### Usage + +Here is an example of how to use the `RichReadOnlyEditor` component + +```tsx + +``` diff --git a/packages/editor/types/package.json b/packages/editor/types/package.json new file mode 100644 index 000000000..ebf25be6a --- /dev/null +++ b/packages/editor/types/package.json @@ -0,0 +1,51 @@ +{ + "name": "@plane/editor-types", + "version": "0.1.0", + "description": "Package that powers Plane's Editor with extensions", + "private": true, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsup --minify", + "dev": "tsup --watch", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "next": "12.3.2", + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" + }, + "dependencies": { + "eslint": "8.36.0", + "eslint-config-next": "13.2.4" + }, + "devDependencies": { + "@tiptap/core": "^2.1.12", + "@types/node": "18.15.3", + "@types/react": "^18.2.39", + "@types/react-dom": "^18.2.14", + "eslint": "^7.32.0", + "tsconfig": "*", + "tsup": "^7.2.0", + "typescript": "4.9.5" + }, + "keywords": [ + "editor", + "rich-text", + "markdown", + "nextjs", + "react" + ] +} \ No newline at end of file diff --git a/packages/editor/types/postcss.config.js b/packages/editor/types/postcss.config.js new file mode 100644 index 000000000..07aa434b2 --- /dev/null +++ b/packages/editor/types/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/editor/types/src/index.ts b/packages/editor/types/src/index.ts new file mode 100644 index 000000000..e7c0ccc1a --- /dev/null +++ b/packages/editor/types/src/index.ts @@ -0,0 +1,8 @@ +export type { DeleteImage } from "./types/delete-image"; +export type { UploadImage } from "./types/upload-image"; +export type { RestoreImage } from "./types/restore-image"; +export type { + IMentionHighlight, + IMentionSuggestion, +} from "./types/mention-suggestion"; +export type { ISlashCommandItem, CommandProps } from "./types/slash-commands-suggestion" diff --git a/packages/editor/core/src/types/delete-image.ts b/packages/editor/types/src/types/delete-image.ts similarity index 100% rename from packages/editor/core/src/types/delete-image.ts rename to packages/editor/types/src/types/delete-image.ts diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/types/src/types/mention-suggestion.ts similarity index 100% rename from packages/editor/core/src/types/mention-suggestion.ts rename to packages/editor/types/src/types/mention-suggestion.ts diff --git a/packages/editor/types/src/types/restore-image.ts b/packages/editor/types/src/types/restore-image.ts new file mode 100644 index 000000000..9b33177b7 --- /dev/null +++ b/packages/editor/types/src/types/restore-image.ts @@ -0,0 +1 @@ +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/types/src/types/slash-commands-suggestion.ts b/packages/editor/types/src/types/slash-commands-suggestion.ts new file mode 100644 index 000000000..327c285cd --- /dev/null +++ b/packages/editor/types/src/types/slash-commands-suggestion.ts @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; +import { Editor, Range } from "@tiptap/core" + +export type CommandProps = { + editor: Editor; + range: Range; +} + +export type ISlashCommandItem = { + title: string; + description: string; + searchTerms: string[]; + icon: ReactNode; + command: ({ editor, range }: CommandProps) => void; +} diff --git a/packages/editor/core/src/types/upload-image.ts b/packages/editor/types/src/types/upload-image.ts similarity index 100% rename from packages/editor/core/src/types/upload-image.ts rename to packages/editor/types/src/types/upload-image.ts diff --git a/packages/editor/types/tailwind.config.js b/packages/editor/types/tailwind.config.js new file mode 100644 index 000000000..f32063158 --- /dev/null +++ b/packages/editor/types/tailwind.config.js @@ -0,0 +1,6 @@ +const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); + +module.exports = { + // prefix ui lib classes to avoid conflicting with the app + ...sharedConfig, +}; diff --git a/packages/editor/types/tsconfig.json b/packages/editor/types/tsconfig.json new file mode 100644 index 000000000..57d0e9a74 --- /dev/null +++ b/packages/editor/types/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/editor/types/tsup.config.ts b/packages/editor/types/tsup.config.ts new file mode 100644 index 000000000..5e89e04af --- /dev/null +++ b/packages/editor/types/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: false, + external: ["react"], + injectStyle: true, + ...options, +})); diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 5aef561e9..97f7cab84 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -36,6 +36,8 @@ module.exports = { "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", + "onbording-shadow-sm": "var(--color-onboarding-shadow-sm)", + }, colors: { custom: { @@ -176,6 +178,25 @@ module.exports = { }, backdrop: "rgba(0, 0, 0, 0.25)", }, + onboarding: { + background: { + 100: convertToRGB("--color-onboarding-background-100"), + 200: convertToRGB("--color-onboarding-background-200"), + 300: convertToRGB("--color-onboarding-background-300"), + 400: convertToRGB("--color-onboarding-background-400"), + }, + text: { + 100: convertToRGB("--color-onboarding-text-100"), + 200: convertToRGB("--color-onboarding-text-200"), + 300: convertToRGB("--color-onboarding-text-300"), + 400: convertToRGB("--color-onboarding-text-400"), + }, + border: { + 100: convertToRGB("--color-onboarding-border-100"), + 200: convertToRGB("--color-onboarding-border-200"), + 300: convertToRGB("--color-onboarding-border-300"), + }, + }, }, keyframes: { leftToaster: { @@ -353,6 +374,11 @@ module.exports = { 80: "18rem", 96: "21.6rem", }, + backgroundImage: { + "onboarding-gradient-100": "var( --gradient-onboarding-100)", + "onboarding-gradient-200": "var( --gradient-onboarding-200)", + "onboarding-gradient-300": "var( --gradient-onboarding-300)", + }, }, fontFamily: { custom: ["Inter", "sans-serif"], diff --git a/packages/ui/package.json b/packages/ui/package.json index 72413eb7c..52ae9522a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.0.1", + "version": "0.1.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -12,16 +12,16 @@ "dist/**" ], "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --external react", + "build": "tsup src/index.ts --format esm,cjs --dts --external react --minify", "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react", "lint": "eslint src/", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, "devDependencies": { - "@types/react-color": "^3.0.9", "@types/node": "^20.5.2", - "@types/react": "18.2.0", - "@types/react-dom": "18.2.0", + "@types/react": "^18.2.42", + "@types/react-color": "^3.0.9", + "@types/react-dom": "^18.2.17", "classnames": "^2.3.2", "eslint-config-custom": "*", "react": "^18.2.0", @@ -30,9 +30,6 @@ "tsup": "^5.10.1", "typescript": "4.7.4" }, - "publishConfig": { - "access": "public" - }, "dependencies": { "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", @@ -41,4 +38,4 @@ "react-color": "^2.19.3", "react-popper": "^2.3.0" } -} +} \ No newline at end of file diff --git a/packages/ui/src/badge/badge.tsx b/packages/ui/src/badge/badge.tsx new file mode 100644 index 000000000..4533b9361 --- /dev/null +++ b/packages/ui/src/badge/badge.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; + +import { + getIconStyling, + getBadgeStyling, + TBadgeVariant, + TBadgeSizes, +} from "./helper"; + +export interface BadgeProps + extends React.ButtonHTMLAttributes { + variant?: TBadgeVariant; + size?: TBadgeSizes; + className?: string; + loading?: boolean; + disabled?: boolean; + appendIcon?: any; + prependIcon?: any; + children: React.ReactNode; +} + +const Badge = React.forwardRef((props, ref) => { + const { + variant = "primary", + size = "md", + className = "", + type = "button", + loading = false, + disabled = false, + prependIcon = null, + appendIcon = null, + children, + ...rest + } = props; + + const buttonStyle = getBadgeStyling(variant, size, disabled || loading); + const buttonIconStyle = getIconStyling(size); + + return ( + + ); +}); + +Badge.displayName = "plane-ui-badge"; + +export { Badge }; diff --git a/packages/ui/src/badge/helper.tsx b/packages/ui/src/badge/helper.tsx new file mode 100644 index 000000000..745b5d047 --- /dev/null +++ b/packages/ui/src/badge/helper.tsx @@ -0,0 +1,145 @@ +export type TBadgeVariant = + | "primary" + | "accent-primary" + | "outline-primary" + | "neutral" + | "accent-neutral" + | "outline-neutral" + | "success" + | "accent-success" + | "outline-success" + | "warning" + | "accent-warning" + | "outline-warning" + | "destructive" + | "accent-destructive" + | "outline-destructive"; + +export type TBadgeSizes = "sm" | "md" | "lg" | "xl"; + +export interface IBadgeStyling { + [key: string]: { + default: string; + hover: string; + disabled: string; + }; +} + +enum badgeSizeStyling { + sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, + md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, + lg = `px-4 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, + xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, +} + +enum badgeIconStyling { + sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0", + md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0", + lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0", + xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0", +} + +export const badgeStyling: IBadgeStyling = { + primary: { + default: `text-white bg-custom-primary-100`, + hover: `hover:bg-custom-primary-200`, + disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`, + }, + "accent-primary": { + default: `bg-custom-primary-10 text-custom-primary-100`, + hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`, + disabled: `cursor-not-allowed !text-custom-primary-60`, + }, + "outline-primary": { + default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`, + hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`, + disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `, + }, + + neutral: { + default: `text-custom-background-100 bg-custom-text-100 border border-custom-border-200`, + hover: `hover:bg-custom-text-200`, + disabled: `cursor-not-allowed bg-custom-border-200 !text-custom-text-400`, + }, + "accent-neutral": { + default: `text-custom-text-200 bg-custom-background-80`, + hover: `hover:bg-custom-border-200 hover:text-custom-text-100`, + disabled: `cursor-not-allowed !text-custom-text-400`, + }, + "outline-neutral": { + default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`, + hover: `hover:text-custom-text-100 hover:bg-custom-border-200`, + disabled: `cursor-not-allowed !text-custom-text-400`, + }, + + success: { + default: `text-white bg-green-500`, + hover: `hover:bg-green-600`, + disabled: `cursor-not-allowed !bg-green-300`, + }, + "accent-success": { + default: `text-green-500 bg-green-50`, + hover: `hover:bg-green-100 hover:text-green-600`, + disabled: `cursor-not-allowed !text-green-300`, + }, + "outline-success": { + default: `text-green-500 bg-custom-background-100 border border-green-500`, + hover: `hover:text-green-600 hover:bg-green-50`, + disabled: `cursor-not-allowed !text-green-300 border-green-300`, + }, + + warning: { + default: `text-white bg-amber-500`, + hover: `hover:bg-amber-600`, + disabled: `cursor-not-allowed !bg-amber-300`, + }, + "accent-warning": { + default: `text-amber-500 bg-amber-50`, + hover: `hover:bg-amber-100 hover:text-amber-600`, + disabled: `cursor-not-allowed !text-amber-300`, + }, + "outline-warning": { + default: `text-amber-500 bg-custom-background-100 border border-amber-500`, + hover: `hover:text-amber-600 hover:bg-amber-50`, + disabled: `cursor-not-allowed !text-amber-300 border-amber-300`, + }, + + destructive: { + default: `text-white bg-red-500`, + hover: `hover:bg-red-600`, + disabled: `cursor-not-allowed !bg-red-300`, + }, + "accent-destructive": { + default: `text-red-500 bg-red-50`, + hover: `hover:bg-red-100 hover:text-red-600`, + disabled: `cursor-not-allowed !text-red-300`, + }, + "outline-destructive": { + default: `text-red-500 bg-custom-background-100 border border-red-500`, + hover: `hover:text-red-600 hover:bg-red-50`, + disabled: `cursor-not-allowed !text-red-300 border-red-300`, + }, +}; + +export const getBadgeStyling = ( + variant: TBadgeVariant, + size: TBadgeSizes, + disabled: boolean = false +): string => { + let _variant: string = ``; + const currentVariant = badgeStyling[variant]; + + _variant = `${currentVariant.default} ${ + disabled ? currentVariant.disabled : currentVariant.hover + }`; + + let _size: string = ``; + if (size) _size = badgeSizeStyling[size]; + return `${_variant} ${_size}`; +}; + +export const getIconStyling = (size: TBadgeSizes): string => { + let icon: string = ``; + if (size) icon = badgeIconStyling[size]; + return icon; +}; diff --git a/packages/ui/src/badge/index.ts b/packages/ui/src/badge/index.ts new file mode 100644 index 000000000..80844a4e3 --- /dev/null +++ b/packages/ui/src/badge/index.ts @@ -0,0 +1 @@ +export * from "./badge"; diff --git a/packages/ui/src/button/helper.tsx b/packages/ui/src/button/helper.tsx index 48b1fc94a..5e6ff6a51 100644 --- a/packages/ui/src/button/helper.tsx +++ b/packages/ui/src/button/helper.tsx @@ -49,9 +49,9 @@ export const buttonStyling: IButtonStyling = { disabled: `cursor-not-allowed !text-custom-primary-60`, }, "outline-primary": { - default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`, - hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`, - pressed: `focus:text-custom-primary-80 focus:bg-custom-primary-10 focus:border-custom-primary-80`, + default: `text-custom-primary-100 bg-transparent border border-custom-primary-100`, + hover: `hover:bg-custom-primary-100/20`, + pressed: `focus:text-custom-primary-100 focus:bg-custom-primary-100/30`, disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `, }, "neutral-primary": { @@ -80,7 +80,7 @@ export const buttonStyling: IButtonStyling = { disabled: `cursor-not-allowed !text-red-300`, }, "outline-danger": { - default: `text-red-500 bg-custom-background-100 border border-red-500`, + default: `text-red-500 bg-transparent border border-red-500`, hover: `hover:text-red-400 hover:border-red-400`, pressed: `focus:text-red-400 focus:border-red-400`, disabled: `cursor-not-allowed !text-red-300 !border-red-300`, diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 0e8d50064..360c53ad6 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -138,7 +138,10 @@ const MenuItem: React.FC = (props) => { className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${ active ? "bg-custom-background-80" : "" } ${className}`} - onClick={onClick} + onClick={(e) => { + close(); + onClick && onClick(e); + }} > {children} diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index b62ff2cb3..41b1d8209 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -51,11 +51,11 @@ const CustomSelect = (props: ICustomSelectProps) => { diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 2c2e012e9..7d7f02694 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -15,24 +15,42 @@ import { IPriorityIcon } from "./type"; export const PriorityIcon: React.FC = ({ priority, className = "", + transparentBg = false }) => { - if (!className || className === "") className = "h-3.5 w-3.5"; + if (!className || className === "") className = "h-4 w-4"; // Convert to lowercase for string comparison const lowercasePriority = priority?.toLowerCase(); + //get priority icon + const getPriorityIcon = (): React.ReactNode => { + switch (lowercasePriority) { + case 'urgent': + return ; + case 'high': + return ; + case 'medium': + return ; + case 'low': + return ; + default: + return ; + } + }; + return ( <> - {lowercasePriority === "urgent" ? ( - - ) : lowercasePriority === "high" ? ( - - ) : lowercasePriority === "medium" ? ( - - ) : lowercasePriority === "low" ? ( - + { transparentBg ? ( + getPriorityIcon() ) : ( - +
+ { getPriorityIcon() } +
)} ); diff --git a/packages/ui/src/icons/type.d.ts b/packages/ui/src/icons/type.d.ts index 0261ab163..65b188e4c 100644 --- a/packages/ui/src/icons/type.d.ts +++ b/packages/ui/src/icons/type.d.ts @@ -7,4 +7,5 @@ export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; export interface IPriorityIcon { priority: TIssuePriorities | null; className?: string; + transparentBg?: boolean | false; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1d75c9271..4b1bb2fcf 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,5 +1,6 @@ export * from "./avatar"; export * from "./breadcrumbs"; +export * from "./badge"; export * from "./button"; export * from "./dropdowns"; export * from "./form-fields"; diff --git a/packages/ui/src/spinners/circular-spinner.tsx b/packages/ui/src/spinners/circular-spinner.tsx index e7e952295..9ac8286f2 100644 --- a/packages/ui/src/spinners/circular-spinner.tsx +++ b/packages/ui/src/spinners/circular-spinner.tsx @@ -17,7 +17,7 @@ export const Spinner: React.FC = ({ aria-hidden="true" height={height} width={width} - className={`mr-2 animate-spin fill-blue-600 text-custom-text-200 ${className}`} + className={`animate-spin fill-blue-600 text-custom-text-200 ${className}`} viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" diff --git a/setup.sh b/setup.sh index e028cc407..e1fa026b7 100755 --- a/setup.sh +++ b/setup.sh @@ -10,4 +10,4 @@ cp ./space/.env.example ./space/.env cp ./apiserver/.env.example ./apiserver/.env # Generate the SECRET_KEY that will be used by django -echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env \ No newline at end of file +echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env \ No newline at end of file diff --git a/space/components/accounts/email-code-form.tsx b/space/components/accounts/email-code-form.tsx deleted file mode 100644 index b760ccfbb..000000000 --- a/space/components/accounts/email-code-form.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import React, { useEffect, useState, useCallback } from "react"; - -// react hook form -import { useForm } from "react-hook-form"; - -// services -import authenticationService from "services/authentication.service"; - -// hooks -import useToast from "hooks/use-toast"; -import useTimer from "hooks/use-timer"; - -// ui -import { Input, PrimaryButton } from "components/ui"; - -// types -type EmailCodeFormValues = { - email: string; - key?: string; - token?: string; -}; - -export const EmailCodeForm = ({ handleSignIn }: any) => { - const [codeSent, setCodeSent] = useState(false); - const [codeResent, setCodeResent] = useState(false); - const [isCodeResending, setIsCodeResending] = useState(false); - const [errorResendingCode, setErrorResendingCode] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const { setToastAlert } = useToast(); - const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer(); - - const { - register, - handleSubmit, - setError, - setValue, - getValues, - watch, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - key: "", - token: "", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode; - - const onSubmit = useCallback( - async ({ email }: EmailCodeFormValues) => { - setErrorResendingCode(false); - await authenticationService - .emailCode({ email }) - .then((res) => { - setValue("key", res.key); - setCodeSent(true); - }) - .catch((err) => { - setErrorResendingCode(true); - setToastAlert({ - title: "Oops!", - type: "error", - message: err?.error, - }); - }); - }, - [setToastAlert, setValue] - ); - - const handleSignin = async (formData: EmailCodeFormValues) => { - setIsLoading(true); - await authenticationService - .magicSignIn(formData) - .then((response) => { - setIsLoading(false); - handleSignIn(response); - }) - .catch((error) => { - setIsLoading(false); - setToastAlert({ - title: "Oops!", - type: "error", - message: error?.response?.data?.error ?? "Enter the correct code to sign in", - }); - setError("token" as keyof EmailCodeFormValues, { - type: "manual", - message: error?.error, - }); - }); - }; - - const emailOld = getValues("email"); - - useEffect(() => { - setErrorResendingCode(false); - }, [emailOld]); - - useEffect(() => { - const submitForm = (e: KeyboardEvent) => { - if (!codeSent && e.key === "Enter") { - e.preventDefault(); - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - } - }; - - if (!codeSent) { - window.addEventListener("keydown", submitForm); - } - - return () => { - window.removeEventListener("keydown", submitForm); - }; - }, [handleSubmit, codeSent, onSubmit, setResendCodeTimer]); - - return ( - <> - {(codeSent || codeResent) && ( -

- We have sent the sign in code. -
- Please check your inbox at {watch("email")} -

- )} -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - /> - {errors.email &&
{errors.email.message}
} -
- - {codeSent && ( - <> - - {errors.token &&
{errors.token.message}
} - - - )} - {codeSent ? ( - - {isLoading ? "Signing in..." : "Sign in"} - - ) : ( - { - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - }} - disabled={!isValid && isDirty} - loading={isSubmitting} - > - {isSubmitting ? "Sending code..." : "Send sign in code"} - - )} - - - ); -}; diff --git a/space/components/accounts/email-password-form.tsx b/space/components/accounts/email-password-form.tsx deleted file mode 100644 index b00740a15..000000000 --- a/space/components/accounts/email-password-form.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useState } from "react"; -import { useRouter } from "next/router"; -import Link from "next/link"; -import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "./email-reset-password-form"; -// ui -import { Input, PrimaryButton } from "components/ui"; -// types -type EmailPasswordFormValues = { - email: string; - password?: string; - medium?: string; -}; - -type Props = { - onSubmit: (formData: EmailPasswordFormValues) => Promise; -}; - -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - - const { - register, - handleSubmit, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - password: "", - medium: "email", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - return ( - <> -

- {isResettingPassword ? "Reset your password" : isSignUpPage ? "Sign up on Plane" : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" - /> - {errors.email &&
{errors.email.message}
} -
-
- - {errors.password &&
{errors.password.message}
} -
-
- {isSignUpPage ? ( - - Already have an account? Sign in. - - ) : ( - - )} -
-
- - {isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"} - - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
- - )} - - ); -}; diff --git a/space/components/accounts/email-reset-password-form.tsx b/space/components/accounts/email-reset-password-form.tsx deleted file mode 100644 index ee71890ec..000000000 --- a/space/components/accounts/email-reset-password-form.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; - -// react hook form -import { useForm } from "react-hook-form"; -// services -import userService from "services/user.service"; -// hooks -// import useToast from "hooks/use-toast"; -// ui -import { Input } from "components/ui"; -import { Button } from "@plane/ui"; -// types -type Props = { - setIsResettingPassword: React.Dispatch>; -}; - -export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword }) => { - // const { setToastAlert } = useToast(); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { - email: "", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const forgotPassword = async (formData: any) => { - const payload = { - email: formData.email, - }; - - // await userService - // .forgotPassword(payload) - // .then(() => - // setToastAlert({ - // type: "success", - // title: "Success!", - // message: "Password reset link has been sent to your email address.", - // }) - // ) - // .catch((err) => { - // if (err.status === 400) - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: "Please check the Email ID entered.", - // }); - // else - // setToastAlert({ - // type: "error", - // title: "Error!", - // message: "Something went wrong. Please try again.", - // }); - // }); - }; - - return ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - })} - placeholder="Enter registered email address.." - className="border-custom-border-300 h-[46px]" - /> - {errors.email &&
{errors.email.message}
} -
-
- - -
- - ); -}; diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index b1bd586fe..2d64261f8 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -38,7 +38,7 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( -
+
= (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: 360, text: "signin_with", - } as any // customization attributes + } as GsiButtonConfiguration // customization attributes ); } catch (err) { console.log(err); @@ -40,7 +39,7 @@ export const GoogleLoginButton: FC = (props) => { (window as any)?.google?.accounts.id.prompt(); // also display the One Tap dialog setGsiScriptLoaded(true); - }, [handleSignIn, gsiScriptLoaded]); + }, [handleSignIn, gsiScriptLoaded, clientId]); useEffect(() => { if ((window as any)?.google?.accounts?.id) { diff --git a/space/components/accounts/index.ts b/space/components/accounts/index.ts index 03a173766..db170180f 100644 --- a/space/components/accounts/index.ts +++ b/space/components/accounts/index.ts @@ -1,8 +1,5 @@ -export * from "./email-code-form"; -export * from "./email-password-form"; -export * from "./email-reset-password-form"; export * from "./github-login-button"; export * from "./google-login"; export * from "./onboarding-form"; -export * from "./sign-in"; export * from "./user-logged-in"; +export * from "./sign-in-forms"; diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index c3cb972b2..4e647506b 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -11,9 +11,9 @@ import { USER_ROLES } from "constants/workspace"; // hooks import useToast from "hooks/use-toast"; // services -import UserService from "services/user.service"; +import { UserService } from "services/user.service"; // ui -import { Input, PrimaryButton } from "components/ui"; +import { Button, Input } from "@plane/ui"; const defaultValues = { first_name: "", @@ -93,6 +93,7 @@ export const OnBoardingForm: React.FC = observer(({ user }) => { = observer(({ user }) => { = observer(({ user }) => {
- + ); }); diff --git a/space/components/accounts/sign-in-forms/create-password.tsx b/space/components/accounts/sign-in-forms/create-password.tsx new file mode 100644 index 000000000..330693e98 --- /dev/null +++ b/space/components/accounts/sign-in-forms/create-password.tsx @@ -0,0 +1,141 @@ +import React, { useEffect } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + isOnboarded: boolean; +}; + +type TCreatePasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TCreatePasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const CreatePasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection, isOnboarded } = props; + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + setFocus, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { + const payload = { + password: formData.password, + }; + + await authService + .setPassword(payload) + .then(async () => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Password created successfully.", + }); + await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("password"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + ( + + )} + /> + +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/email-form.tsx b/space/components/accounts/sign-in-forms/email-form.tsx new file mode 100644 index 000000000..23967642c --- /dev/null +++ b/space/components/accounts/sign-in-forms/email-form.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + handleStepChange: (step: ESignInSteps) => void; + updateEmail: (email: string) => void; +}; + +type TEmailFormValues = { + email: string; +}; + +const authService = new AuthService(); + +export const EmailForm: React.FC = (props) => { + const { handleStepChange, updateEmail } = props; + + const { setToastAlert } = useToast(); + + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + setFocus, + } = useForm({ + defaultValues: { + email: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (data: TEmailFormValues) => { + const payload: IEmailCheckData = { + email: data.email, + }; + + // update the global email state + updateEmail(data.email); + + await authService + .emailCheck(payload) + .then((res) => { + // if the password has been autoset, send the user to magic sign-in + if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE); + // if the password has not been autoset, send them to password sign-in + else handleStepChange(ESignInSteps.PASSWORD); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("email"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+

+ Create or join a workspace. Start with your e-mail. +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+ + + + ); +}; diff --git a/space/components/accounts/sign-in-forms/index.ts b/space/components/accounts/sign-in-forms/index.ts new file mode 100644 index 000000000..1150a071c --- /dev/null +++ b/space/components/accounts/sign-in-forms/index.ts @@ -0,0 +1,9 @@ +export * from "./create-password"; +export * from "./email-form"; +export * from "./o-auth-options"; +export * from "./optional-set-password"; +export * from "./password"; +export * from "./root"; +export * from "./self-hosted-sign-in"; +export * from "./set-password-link"; +export * from "./unique-code"; diff --git a/space/components/accounts/sign-in-forms/o-auth-options.tsx b/space/components/accounts/sign-in-forms/o-auth-options.tsx new file mode 100644 index 000000000..601db6721 --- /dev/null +++ b/space/components/accounts/sign-in-forms/o-auth-options.tsx @@ -0,0 +1,86 @@ +import useSWR from "swr"; + +import { observer } from "mobx-react-lite"; +// services +import { AuthService } from "services/authentication.service"; +import { AppConfigService } from "services/app-config.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { GithubLoginButton, GoogleLoginButton } from "components/accounts"; + +type Props = { + handleSignInRedirection: () => Promise; +}; + +// services +const authService = new AuthService(); +const appConfig = new AppConfigService(); + +export const OAuthOptions: React.FC = observer((props) => { + const { handleSignInRedirection } = props; + // toast alert + const { setToastAlert } = useToast(); + + const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig()); + + const handleGoogleSignIn = async ({ clientId, credential }: any) => { + try { + if (clientId && credential) { + const socialAuthPayload = { + medium: "google", + credential, + clientId, + }; + const response = await authService.socialAuth(socialAuthPayload); + + if (response) handleSignInRedirection(); + } else throw Error("Cant find credentials"); + } catch (err: any) { + setToastAlert({ + title: "Error signing in!", + type: "error", + message: err?.error || "Something went wrong. Please try again later or contact the support team.", + }); + } + }; + + const handleGitHubSignIn = async (credential: string) => { + try { + if (envConfig && envConfig.github_client_id && credential) { + const socialAuthPayload = { + medium: "github", + credential, + clientId: envConfig.github_client_id, + }; + const response = await authService.socialAuth(socialAuthPayload); + + if (response) handleSignInRedirection(); + } else throw Error("Cant find credentials"); + } catch (err: any) { + setToastAlert({ + title: "Error signing in!", + type: "error", + message: err?.error || "Something went wrong. Please try again later or contact the support team.", + }); + } + }; + + return ( + <> +
+
+

Or continue with

+
+
+
+ {envConfig?.google_client_id && ( + + )} + {envConfig?.github_client_id && ( + + )} +
+ + ); +}); diff --git a/space/components/accounts/sign-in-forms/optional-set-password.tsx b/space/components/accounts/sign-in-forms/optional-set-password.tsx new file mode 100644 index 000000000..ffe8ae5cd --- /dev/null +++ b/space/components/accounts/sign-in-forms/optional-set-password.tsx @@ -0,0 +1,104 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + isOnboarded: boolean; +}; + +export const OptionalSetPasswordForm: React.FC = (props) => { + const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; + // states + const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + // form info + const { + control, + formState: { errors, isValid }, + } = useForm({ + defaultValues: { + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleGoToWorkspace = async () => { + setIsGoingToWorkspace(true); + + await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + }; + + return ( + <> +

Set a password

+

+ If you{"'"}d like to do away with codes, set a password here. +

+ +
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> +
+ + +
+

+ When you click{" "} + {isOnboarded ? "Go to board" : "Set up profile"} above, you + agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/password.tsx b/space/components/accounts/sign-in-forms/password.tsx new file mode 100644 index 000000000..9b8c4211c --- /dev/null +++ b/space/components/accounts/sign-in-forms/password.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IPasswordSignInData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; +}; + +type TPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const PasswordForm: React.FC = (props) => { + const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; + // states + const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); + const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting, isValid }, + getValues, + handleSubmit, + setError, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (formData: TPasswordFormValues) => { + updateEmail(formData.email); + + const payload: IPasswordSignInData = { + email: formData.email, + password: formData.password, + }; + + await authService + .passwordSignIn(payload) + .then(async () => await handleSignInRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleForgotPassword = async () => { + const emailFormValue = getValues("email"); + + const isEmailValid = checkEmailValidity(emailFormValue); + + if (!isEmailValid) { + setError("email", { message: "Email is invalid" }); + return; + } + + setIsSendingResetPasswordLink(true); + + authService + .sendResetPasswordLink({ email: emailFormValue }) + .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsSendingResetPasswordLink(false)); + }; + + const handleSendUniqueCode = async () => { + const emailFormValue = getValues("email"); + + const isEmailValid = checkEmailValidity(emailFormValue); + + if (!isEmailValid) { + setError("email", { message: "Email is invalid" }); + return; + } + + setIsSendingUniqueCode(true); + + await authService + .generateUniqueCode({ email: emailFormValue }) + .then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsSendingUniqueCode(false)); + }; + + useEffect(() => { + setFocus("password"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+
+ ( + + )} + /> +
+ +
+
+
+ + +
+

+ When you click Go to board above, you agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/root.tsx b/space/components/accounts/sign-in-forms/root.tsx new file mode 100644 index 000000000..c36842ce7 --- /dev/null +++ b/space/components/accounts/sign-in-forms/root.tsx @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// hooks +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// services +import { AppConfigService } from "services/app-config.service"; +// components +import { LatestFeatureBlock } from "components/common"; +import { + EmailForm, + UniqueCodeForm, + PasswordForm, + SetPasswordLink, + OAuthOptions, + OptionalSetPasswordForm, + CreatePasswordForm, + SelfHostedSignInForm, +} from "components/accounts"; + +export enum ESignInSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + SET_PASSWORD_LINK = "SET_PASSWORD_LINK", + UNIQUE_CODE = "UNIQUE_CODE", + OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", + CREATE_PASSWORD = "CREATE_PASSWORD", + USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", +} + +const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; + +const appConfig = new AppConfigService(); + +export const SignInRoot = observer(() => { + // states + const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); + const [email, setEmail] = useState(""); + const [isOnboarded, setIsOnboarded] = useState(false); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); + + const { data: envConfig } = useSWR("APP_CONFIG", () => appConfig.envConfig()); + + const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); + return ( + <> +
+ {envConfig?.is_self_managed ? ( + setEmail(newEmail)} + handleSignInRedirection={handleRedirection} + /> + ) : ( + <> + {signInStep === ESignInSteps.EMAIL && ( + setSignInStep(step)} + updateEmail={(newEmail) => setEmail(newEmail)} + /> + )} + {signInStep === ESignInSteps.PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + /> + )} + {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + submitButtonLabel="Go to board" + showTermsAndConditions + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + /> + )} + {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + {signInStep === ESignInSteps.CREATE_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + + )} +
+ {isOAuthEnabled && + !OAUTH_HIDDEN_STEPS.includes(signInStep) && + signInStep !== ESignInSteps.CREATE_PASSWORD && + signInStep !== ESignInSteps.PASSWORD && } + + + ); +}); diff --git a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx new file mode 100644 index 000000000..6ad0cfd8a --- /dev/null +++ b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx @@ -0,0 +1,144 @@ +import React, { useEffect } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IPasswordSignInData } from "types/auth"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleSignInRedirection: () => Promise; +}; + +type TPasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TPasswordFormValues = { + email: "", + password: "", +}; + +const authService = new AuthService(); + +export const SelfHostedSignInForm: React.FC = (props) => { + const { email, updateEmail, handleSignInRedirection } = props; + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting }, + handleSubmit, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (formData: TPasswordFormValues) => { + const payload: IPasswordSignInData = { + email: formData.email, + password: formData.password, + }; + + updateEmail(formData.email); + + await authService + .passwordSignIn(payload) + .then(async () => await handleSignInRedirection()) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + useEffect(() => { + setFocus("email"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+
+ ( + + )} + /> +
+ +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+ + + ); +}; diff --git a/space/components/accounts/sign-in-forms/set-password-link.tsx b/space/components/accounts/sign-in-forms/set-password-link.tsx new file mode 100644 index 000000000..b0331f7e0 --- /dev/null +++ b/space/components/accounts/sign-in-forms/set-password-link.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/authentication.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData } from "types/auth"; + +type Props = { + email: string; + updateEmail: (email: string) => void; +}; + +const authService = new AuthService(); + +export const SetPasswordLink: React.FC = (props) => { + const { email, updateEmail } = props; + + const { setToastAlert } = useToast(); + + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleSendNewLink = async (formData: { email: string }) => { + updateEmail(formData.email); + + const payload: IEmailCheckData = { + email: formData.email, + }; + + await authService + .sendResetPasswordLink(payload) + .then(() => + setToastAlert({ + type: "success", + title: "Success!", + message: "We have sent a new link to your email.", + }) + ) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + return ( + <> +

+ Get on your flight deck +

+

+ We have sent a link to {email}, so you can set a + password +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( + + )} + /> +
+ + + + ); +}; diff --git a/space/components/accounts/sign-in-forms/unique-code.tsx b/space/components/accounts/sign-in-forms/unique-code.tsx new file mode 100644 index 000000000..638023bc7 --- /dev/null +++ b/space/components/accounts/sign-in-forms/unique-code.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { CornerDownLeft, XCircle } from "lucide-react"; +// services +import { AuthService } from "services/authentication.service"; +import { UserService } from "services/user.service"; +// hooks +import useToast from "hooks/use-toast"; +import useTimer from "hooks/use-timer"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData, IMagicSignInData } from "types/auth"; +// constants +import { ESignInSteps } from "components/accounts"; + +type Props = { + email: string; + updateEmail: (email: string) => void; + handleStepChange: (step: ESignInSteps) => void; + handleSignInRedirection: () => Promise; + submitButtonLabel?: string; + showTermsAndConditions?: boolean; + updateUserOnboardingStatus: (value: boolean) => void; +}; + +type TUniqueCodeFormValues = { + email: string; + token: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + token: "", +}; + +// services +const authService = new AuthService(); +const userService = new UserService(); + +export const UniqueCodeForm: React.FC = (props) => { + const { + email, + updateEmail, + handleStepChange, + handleSignInRedirection, + submitButtonLabel = "Continue", + showTermsAndConditions = false, + updateUserOnboardingStatus, + } = props; + // states + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); + // form info + const { + control, + formState: { dirtyFields, errors, isSubmitting, isValid }, + getValues, + handleSubmit, + reset, + setFocus, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => { + const payload: IMagicSignInData = { + email: formData.email, + key: `magic_${formData.email}`, + token: formData.token, + }; + + await authService + .magicSignIn(payload) + .then(async () => { + const currentUser = await userService.currentUser(); + + updateUserOnboardingStatus(currentUser.onboarding_step.profile_complete ?? false); + + if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); + else await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { + const payload: IEmailCheckData = { + email: formData.email, + }; + + await authService + .generateUniqueCode(payload) + .then(() => { + setResendCodeTimer(30); + setToastAlert({ + type: "success", + title: "Success!", + message: "A new unique code has been sent to your email.", + }); + + reset({ + email: formData.email, + token: "", + }); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { + updateEmail(formData.email); + + if (dirtyFields.email) await handleSendNewCode(formData); + else await handleUniqueCodeSignIn(formData); + }; + + const handleRequestNewCode = async () => { + setIsRequestingNewCode(true); + + await handleSendNewCode(getValues()) + .then(() => setResendCodeTimer(30)) + .finally(() => setIsRequestingNewCode(false)); + }; + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const hasEmailChanged = dirtyFields.email; + + useEffect(() => { + setFocus("token"); + }, [setFocus]); + + return ( + <> +

+ Get on your flight deck +

+

+ Paste the code you got at {email} below. +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( +
+ { + if (hasEmailChanged) handleSendNewCode(getValues()); + }} + ref={ref} + hasError={Boolean(errors.email)} + placeholder="orville.wright@firstflight.com" + className="w-full h-[46px] placeholder:text-onboarding-text-400 border border-onboarding-border-100 pr-12" + /> + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> + {hasEmailChanged && ( + + )} +
+
+ ( + + )} + /> +
+ +
+
+ + {showTermsAndConditions && ( +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+ )} + + + ); +}; diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx deleted file mode 100644 index b55824e6c..000000000 --- a/space/components/accounts/sign-in.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from "react"; -import useSWR from "swr"; -import { useRouter } from "next/router"; -// mobx -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import authenticationService from "services/authentication.service"; -import { AppConfigService } from "services/app-config.service"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts"; -// images -const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; - -const appConfig = new AppConfigService(); - -export const SignInView = observer(() => { - const { user: userStore } = useMobxStore(); - // router - const router = useRouter(); - const { next_path } = router.query as { next_path: string }; - // toast - const { setToastAlert } = useToast(); - // fetch app config - const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig()); - - const onSignInError = (error: any) => { - setToastAlert({ - title: "Error signing in!", - type: "error", - message: error?.error || "Something went wrong. Please try again later or contact the support team.", - }); - }; - - const onSignInSuccess = (response: any) => { - userStore.setCurrentUser(response?.user); - - const isOnboard = response?.user?.onboarding_step?.profile_complete || false; - - if (isOnboard) { - if (next_path) router.push(next_path); - else router.push("/login"); - } else { - if (next_path) router.push(`/onboarding?next_path=${next_path}`); - else router.push("/onboarding"); - } - }; - - const handleGoogleSignIn = async ({ clientId, credential }: any) => { - try { - if (clientId && credential) { - const socialAuthPayload = { - medium: "google", - credential, - clientId, - }; - const response = await authenticationService.socialAuth(socialAuthPayload); - - onSignInSuccess(response); - } else { - throw Error("Cant find credentials"); - } - } catch (err: any) { - onSignInError(err); - } - }; - - const handlePasswordSignIn = async (formData: any) => { - await authenticationService - .emailLogin(formData) - .then((response) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }) - .catch((err) => onSignInError(err)); - }; - - const handleEmailCodeSignIn = async (response: any) => { - try { - if (response) { - onSignInSuccess(response); - } - } catch (err: any) { - onSignInError(err); - } - }; - - return ( -
-
-
-
-
- Plane Logo -
-
-
-
-
-

Sign in to Plane

- {data?.email_password_login && } - - {data?.magic_login && ( -
-
- -
-
- )} - -
- {data?.google_client_id && ( - - )} -
- -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

-
-
-
- ); -}); diff --git a/space/components/common/index.ts b/space/components/common/index.ts new file mode 100644 index 000000000..f1c0b088e --- /dev/null +++ b/space/components/common/index.ts @@ -0,0 +1 @@ +export * from "./latest-feature-block"; diff --git a/space/components/common/latest-feature-block.tsx b/space/components/common/latest-feature-block.tsx new file mode 100644 index 000000000..5abbbf603 --- /dev/null +++ b/space/components/common/latest-feature-block.tsx @@ -0,0 +1,36 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// icons +import { Lightbulb } from "lucide-react"; +// images +import latestFeatures from "public/onboarding/onboarding-pages.svg"; + +export const LatestFeatureBlock = () => { + const { resolvedTheme } = useTheme(); + + return ( + <> +
+ +

+ Pages gets a facelift! Write anything and use Galileo to help you start.{" "} + + Learn more + +

+
+
+
+ +
+
+ + ); +}; diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx index b2effc4ad..3623fb094 100644 --- a/space/components/issues/board-views/kanban/block.tsx +++ b/space/components/issues/board-views/kanban/block.tsx @@ -13,22 +13,30 @@ import { IIssue } from "types/issue"; import { RootStore } from "store/root"; import { useRouter } from "next/router"; -export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => { +export const IssueKanBanBlock = observer(({ issue }: { issue: IIssue }) => { const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); // router const router = useRouter(); - const { workspace_slug, project_slug, board } = router.query; + const { workspace_slug, project_slug, board, priorities, states, labels } = router.query as { + workspace_slug: string; + project_slug: string; + board: string; + priorities: string; + states: string; + labels: string; + }; const handleBlockClick = () => { issueDetailStore.setPeekId(issue.id); + const params: any = { board: board, peekId: issue.id }; + if (states && states.length > 0) params.states = states; + if (priorities && priorities.length > 0) params.priorities = priorities; + if (labels && labels.length > 0) params.labels = labels; router.push( { - pathname: `/${workspace_slug?.toString()}/${project_slug}`, - query: { - board: board?.toString(), - peekId: issue.id, - }, + pathname: `/${workspace_slug}/${project_slug}`, + query: { ...params }, }, undefined, { shallow: true } @@ -43,7 +51,11 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
{/* name */} -
+
{issue.name}
diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index 8f2f28496..488d94b59 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -10,7 +10,7 @@ import { StateGroupIcon } from "@plane/ui"; import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; -export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { +export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { const store: RootStore = useMobxStore(); const stateGroup = issueGroupFilter(state.group); diff --git a/space/components/issues/board-views/kanban/index.tsx b/space/components/issues/board-views/kanban/index.tsx index b45b037d2..cc00f931e 100644 --- a/space/components/issues/board-views/kanban/index.tsx +++ b/space/components/issues/board-views/kanban/index.tsx @@ -3,8 +3,8 @@ // mobx react lite import { observer } from "mobx-react-lite"; // components -import { IssueListHeader } from "components/issues/board-views/kanban/header"; -import { IssueListBlock } from "components/issues/board-views/kanban/block"; +import { IssueKanBanHeader } from "components/issues/board-views/kanban/header"; +import { IssueKanBanBlock } from "components/issues/board-views/kanban/block"; // ui import { Icon } from "components/ui"; // interfaces @@ -23,14 +23,14 @@ export const IssueKanbanView = observer(() => { store?.issue?.states.map((_state: IIssueState) => (
- +
{store.issue.getFilteredIssuesByState(_state.id) && store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - + ))}
) : ( diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/board-views/list/block.tsx index 57011d033..45999fa96 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/board-views/list/block.tsx @@ -19,17 +19,25 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); // router const router = useRouter(); - const { workspace_slug, project_slug, board } = router.query; + const { workspace_slug, project_slug, board, priorities, states, labels } = router.query as { + workspace_slug: string; + project_slug: string; + board: string; + priorities: string; + states: string; + labels: string; + }; const handleBlockClick = () => { issueDetailStore.setPeekId(issue.id); + const params: any = { board: board, peekId: issue.id }; + if (states && states.length > 0) params.states = states; + if (priorities && priorities.length > 0) params.priorities = priorities; + if (labels && labels.length > 0) params.labels = labels; router.push( { - pathname: `/${workspace_slug?.toString()}/${project_slug}`, - query: { - board: board?.toString(), - peekId: issue.id, - }, + pathname: `/${workspace_slug}/${project_slug}`, + query: { ...params }, }, undefined, { shallow: true } diff --git a/space/components/issues/filters-render/index.tsx b/space/components/issues/filters-render/index.tsx deleted file mode 100644 index d797d1506..000000000 --- a/space/components/issues/filters-render/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import IssueStateFilter from "./state"; -import IssueLabelFilter from "./label"; -import IssuePriorityFilter from "./priority"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearAllFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "all", - // removeAll: true, - // }) - // ); - }; - - // if (store.issue.getIfFiltersIsEmpty()) return null; - - return ( -
-
- {/* state */} - {/* {store.issue.checkIfFilterExistsForKey("state") && } */} - {/* labels */} - {/* {store.issue.checkIfFilterExistsForKey("label") && } */} - {/* priority */} - {/* {store.issue.checkIfFilterExistsForKey("priority") && } */} - {/* clear all filters */} -
-
Clear all filters
-
- close -
-
-
-
- ); -}); - -export default IssueFilter; diff --git a/space/components/issues/filters-render/label/filter-label-block.tsx b/space/components/issues/filters-render/label/filter-label-block.tsx deleted file mode 100644 index a54fb65e4..000000000 --- a/space/components/issues/filters-render/label/filter-label-block.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// interfaces -import { IIssueLabel } from "types/issue"; - -export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const removeLabelFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "label", - // value: label?.id, - // }) - // ); - }; - - return ( -
-
- -
{label?.name}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/label/index.tsx b/space/components/issues/filters-render/label/index.tsx deleted file mode 100644 index 1d9a4f990..000000000 --- a/space/components/issues/filters-render/label/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import { RenderIssueLabel } from "./filter-label-block"; -// interfaces -import { IIssueLabel } from "types/issue"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueLabelFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearLabelFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "label", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
Labels
-
- {/* {store?.issue?.labels && - store?.issue?.labels.map( - (_label: IIssueLabel, _index: number) => - store.issue.getUserSelectedFilter("label", _label.id) && ( - - ) - )} */} -
-
- close -
-
- - ); -}); - -export default IssueLabelFilter; diff --git a/space/components/issues/filters-render/priority/filter-priority-block.tsx b/space/components/issues/filters-render/priority/filter-priority-block.tsx deleted file mode 100644 index 5fd1ef1a7..000000000 --- a/space/components/issues/filters-render/priority/filter-priority-block.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// interfaces -import { IIssuePriorityFilters } from "types/issue"; - -export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const removePriorityFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "priority", - // value: priority?.key, - // }) - // ); - }; - - return ( -
-
- {priority?.icon} -
-
{priority?.title}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/priority/index.tsx b/space/components/issues/filters-render/priority/index.tsx deleted file mode 100644 index 100ba1761..000000000 --- a/space/components/issues/filters-render/priority/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { RenderIssuePriority } from "./filter-priority-block"; -// interfaces -import { IIssuePriorityFilters } from "types/issue"; -// constants -import { issuePriorityFilters } from "constants/data"; - -const IssuePriorityFilter = observer(() => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearPriorityFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "priority", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
Priority
-
- {/* {issuePriorityFilters.map( - (_priority: IIssuePriorityFilters, _index: number) => - store.issue.getUserSelectedFilter("priority", _priority.key) && ( - - ) - )} */} -
-
{ - clearPriorityFilters(); - }} - > - close -
-
- - ); -}); - -export default IssuePriorityFilter; diff --git a/space/components/issues/filters-render/state/filter-state-block.tsx b/space/components/issues/filters-render/state/filter-state-block.tsx deleted file mode 100644 index 9b6447cb6..000000000 --- a/space/components/issues/filters-render/state/filter-state-block.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// interfaces -import { IIssueState } from "types/issue"; -// constants -import { issueGroupFilter } from "constants/data"; - -export const RenderIssueState = observer(({ state }: { state: IIssueState }) => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const stateGroup = issueGroupFilter(state.group); - - const removeStateFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "state", - // value: state?.id, - // }) - // ); - }; - - if (stateGroup === null) return <>; - return ( -
-
- {/* */} -
-
{state?.name}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/state/index.tsx b/space/components/issues/filters-render/state/index.tsx deleted file mode 100644 index 0198c5215..000000000 --- a/space/components/issues/filters-render/state/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import { RenderIssueState } from "./filter-state-block"; -// interfaces -import { IIssueState } from "types/issue"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueStateFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearStateFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "state", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
State
-
- {/* {store?.issue?.states && - store?.issue?.states.map( - (_state: IIssueState, _index: number) => - store.issue.getUserSelectedFilter("state", _state.id) && ( - - ) - )} */} -
-
- close -
-
- - ); -}); - -export default IssueStateFilter; diff --git a/space/components/issues/filters/applied-filters/filters-list.tsx b/space/components/issues/filters/applied-filters/filters-list.tsx new file mode 100644 index 000000000..9b8432eb0 --- /dev/null +++ b/space/components/issues/filters/applied-filters/filters-list.tsx @@ -0,0 +1,79 @@ +// components +import { AppliedPriorityFilters } from "./priority"; +import { AppliedStateFilters } from "./state"; +// icons +import { X } from "lucide-react"; +// helpers +import { IIssueFilterOptions } from "store/issues/types"; +import { IIssueLabel, IIssueState } from "types/issue"; +// types + +type Props = { + appliedFilters: IIssueFilterOptions; + handleRemoveAllFilters: () => void; + handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void; + labels?: IIssueLabel[] | undefined; + states?: IIssueState[] | undefined; +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const AppliedFiltersList: React.FC = (props) => { + const { appliedFilters, handleRemoveAllFilters, handleRemoveFilter, states } = props; + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof IIssueFilterOptions; + + if (!value) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "priority" && ( + handleRemoveFilter("priority", val)} values={value} /> + )} + + {/* {filterKey === "labels" && labels && ( + handleRemoveFilter("labels", val)} + labels={labels} + values={value} + /> + )} */} + + {filterKey === "state" && states && ( + handleRemoveFilter("state", val)} + states={states} + values={value} + /> + )} + + +
+
+ ); + })} + +
+ ); +}; diff --git a/space/components/issues/filters/applied-filters/label.tsx b/space/components/issues/filters/applied-filters/label.tsx new file mode 100644 index 000000000..ecf824210 --- /dev/null +++ b/space/components/issues/filters/applied-filters/label.tsx @@ -0,0 +1,42 @@ +import { X } from "lucide-react"; +// types +import { IIssueLabel } from "types/issue"; + +type Props = { + handleRemove: (val: string) => void; + labels: IIssueLabel[] | undefined; + values: string[]; +}; + +export const AppliedLabelsFilters: React.FC = (props) => { + const { handleRemove, labels, values } = props; + + return ( + <> + {values.map((labelId) => { + const labelDetails = labels?.find((l) => l.id === labelId); + + if (!labelDetails) return null; + + return ( +
+ + {labelDetails.name} + +
+ ); + })} + + ); +}; diff --git a/space/components/issues/filters/applied-filters/priority.tsx b/space/components/issues/filters/applied-filters/priority.tsx new file mode 100644 index 000000000..f051abf2d --- /dev/null +++ b/space/components/issues/filters/applied-filters/priority.tsx @@ -0,0 +1,31 @@ +import { PriorityIcon } from "@plane/ui"; +import { X } from "lucide-react"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedPriorityFilters: React.FC = (props) => { + const { handleRemove, values } = props; + + return ( + <> + {values && + values.length > 0 && + values.map((priority) => ( +
+ + {priority} + +
+ ))} + + ); +}; diff --git a/space/components/issues/filters/applied-filters/root.tsx b/space/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 000000000..3f77dcc06 --- /dev/null +++ b/space/components/issues/filters/applied-filters/root.tsx @@ -0,0 +1,90 @@ +import { FC, useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// components +import { AppliedFiltersList } from "./filters-list"; +// store +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +import { IIssueFilterOptions } from "store/issues/types"; + +export const IssueAppliedFilters: FC = observer(() => { + const router = useRouter(); + const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { + workspace_slug: string; + project_slug: string; + }; + + const { + issuesFilter: { issueFilters, updateFilters }, + issue: { states, labels }, + project: { activeBoard }, + }: RootStore = useMobxStore(); + + const userFilters = issueFilters?.filters || {}; + + const appliedFilters: IIssueFilterOptions = {}; + Object.entries(userFilters).forEach(([key, value]) => { + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + appliedFilters[key as keyof IIssueFilterOptions] = value; + }); + + const updateRouteParams = useCallback( + (key: keyof IIssueFilterOptions | null, value: string[] | null, clearFields: boolean = false) => { + const state = key === "state" ? value || [] : issueFilters?.filters?.state ?? []; + const priority = key === "priority" ? value || [] : issueFilters?.filters?.priority ?? []; + const labels = key === "labels" ? value || [] : issueFilters?.filters?.labels ?? []; + + let params: any = { board: activeBoard || "list" }; + if (!clearFields) { + if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; + if (state.length > 0) params = { ...params, states: state.join(",") }; + if (labels.length > 0) params = { ...params, labels: labels.join(",") }; + } + + router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + }, + [workspaceSlug, projectId, activeBoard, issueFilters, router] + ); + + const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { + if (!projectId) return; + if (!value) { + updateFilters(projectId, { [key]: null }); + return; + } + + let newValues = issueFilters?.filters?.[key] ?? []; + newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId, { [key]: newValues }); + updateRouteParams(key, newValues); + }; + + const handleRemoveAllFilters = () => { + if (!projectId) return; + + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + + updateFilters(projectId, { ...newFilters }); + updateRouteParams(null, null, true); + }; + + if (Object.keys(appliedFilters).length === 0) return null; + + return ( +
+ +
+ ); +}); diff --git a/space/components/issues/filters/applied-filters/state.tsx b/space/components/issues/filters/applied-filters/state.tsx new file mode 100644 index 000000000..f238197b8 --- /dev/null +++ b/space/components/issues/filters/applied-filters/state.tsx @@ -0,0 +1,39 @@ +import { X } from "lucide-react"; +import { StateGroupIcon } from "@plane/ui"; +// icons +import { IIssueState } from "types/issue"; +// types + +type Props = { + handleRemove: (val: string) => void; + states: IIssueState[]; + values: string[]; +}; + +export const AppliedStateFilters: React.FC = (props) => { + const { handleRemove, states, values } = props; + + return ( + <> + {values.map((stateId) => { + const stateDetails = states?.find((s) => s.id === stateId); + + if (!stateDetails) return null; + + return ( +
+ + {stateDetails.name} + +
+ ); + })} + + ); +}; diff --git a/space/components/issues/filters/helpers/dropdown.tsx b/space/components/issues/filters/helpers/dropdown.tsx new file mode 100644 index 000000000..d92415b96 --- /dev/null +++ b/space/components/issues/filters/helpers/dropdown.tsx @@ -0,0 +1,63 @@ +import React, { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover, Transition } from "@headlessui/react"; +import { Placement } from "@popperjs/core"; +// ui +import { Button } from "@plane/ui"; + +type Props = { + children: React.ReactNode; + title?: string; + placement?: Placement; +}; + +export const FiltersDropdown: React.FC = (props) => { + const { children, title = "Dropdown", placement } = props; + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + + return ( + + {({ open }) => { + if (open) { + } + return ( + <> + + + + + +
+
{children}
+
+
+
+ + ); + }} +
+ ); +}; diff --git a/space/components/issues/filters/helpers/filter-header.tsx b/space/components/issues/filters/helpers/filter-header.tsx new file mode 100644 index 000000000..4513b0795 --- /dev/null +++ b/space/components/issues/filters/helpers/filter-header.tsx @@ -0,0 +1,22 @@ +import React from "react"; +// lucide icons +import { ChevronDown, ChevronUp } from "lucide-react"; + +interface IFilterHeader { + title: string; + isPreviewEnabled: boolean; + handleIsPreviewEnabled: () => void; +} + +export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => ( +
+
{title}
+ +
+); diff --git a/space/components/issues/filters/helpers/filter-option.tsx b/space/components/issues/filters/helpers/filter-option.tsx new file mode 100644 index 000000000..4b6f1b041 --- /dev/null +++ b/space/components/issues/filters/helpers/filter-option.tsx @@ -0,0 +1,35 @@ +import React from "react"; +// lucide icons +import { Check } from "lucide-react"; + +type Props = { + icon?: React.ReactNode; + isChecked: boolean; + title: React.ReactNode; + onClick?: () => void; + multiple?: boolean; +}; + +export const FilterOption: React.FC = (props) => { + const { icon, isChecked, multiple = true, onClick, title } = props; + + return ( + + ); +}; diff --git a/space/components/issues/filters/helpers/index.ts b/space/components/issues/filters/helpers/index.ts new file mode 100644 index 000000000..ef38d9884 --- /dev/null +++ b/space/components/issues/filters/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./dropdown"; +export * from "./filter-header"; +export * from "./filter-option"; diff --git a/space/components/issues/filters/index.ts b/space/components/issues/filters/index.ts new file mode 100644 index 000000000..56a01386d --- /dev/null +++ b/space/components/issues/filters/index.ts @@ -0,0 +1,11 @@ +// filters +export * from "./root"; +export * from "./selection"; + +// properties +export * from "./state"; +export * from "./priority"; +export * from "./labels"; + +// helpers +export * from "./helpers"; diff --git a/space/components/issues/filters/labels.tsx b/space/components/issues/filters/labels.tsx new file mode 100644 index 000000000..4b8aa3b4f --- /dev/null +++ b/space/components/issues/filters/labels.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; + +// components +import { FilterHeader, FilterOption } from "./helpers"; +// ui +import { Loader } from "@plane/ui"; +// types +import { IIssueLabel } from "types/issue"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export const FilterLabels: React.FC = (props) => { + const { appliedFilters, handleUpdate, labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((label) => ( + handleUpdate(label?.id)} + icon={} + title={label.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}; diff --git a/space/components/issues/filters/priority.tsx b/space/components/issues/filters/priority.tsx new file mode 100644 index 000000000..94a7f6a8c --- /dev/null +++ b/space/components/issues/filters/priority.tsx @@ -0,0 +1,51 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// ui +import { PriorityIcon } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "./helpers"; +// constants +import { issuePriorityFilters } from "constants/data"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterPriority: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = issuePriorityFilters.filter((p) => p.key.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((priority) => ( + handleUpdate(priority.key)} + icon={} + title={priority.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/space/components/issues/filters/root.tsx b/space/components/issues/filters/root.tsx new file mode 100644 index 000000000..5e910a97e --- /dev/null +++ b/space/components/issues/filters/root.tsx @@ -0,0 +1,77 @@ +import { FC, useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// components +import { FiltersDropdown } from "./helpers/dropdown"; +import { FilterSelection } from "./selection"; +// types +import { IIssueFilterOptions } from "store/issues/types"; +// helpers +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "store/issues/helpers"; +// store +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; + +export const IssueFiltersDropdown: FC = observer(() => { + const router = useRouter(); + const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { + workspace_slug: string; + project_slug: string; + }; + + const { + project: { activeBoard }, + issue: { states, labels }, + issuesFilter: { issueFilters, updateFilters }, + }: RootStore = useMobxStore(); + + const updateRouteParams = useCallback( + (key: keyof IIssueFilterOptions, value: string[]) => { + const state = key === "state" ? value : issueFilters?.filters?.state ?? []; + const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? []; + const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; + + let params: any = { board: activeBoard || "list" }; + if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; + if (state.length > 0) params = { ...params, states: state.join(",") }; + if (labels.length > 0) params = { ...params, labels: labels.join(",") }; + + router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + }, + [workspaceSlug, projectId, activeBoard, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId, { [key]: newValues }); + updateRouteParams(key, newValues); + }, + [projectId, issueFilters, updateFilters, updateRouteParams] + ); + + return ( +
+ + + +
+ ); +}); diff --git a/space/components/issues/filters/selection.tsx b/space/components/issues/filters/selection.tsx new file mode 100644 index 000000000..e0a141ef9 --- /dev/null +++ b/space/components/issues/filters/selection.tsx @@ -0,0 +1,86 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterPriority, FilterState } from "./"; +// types + +// filter helpers +import { ILayoutDisplayFiltersOptions } from "store/issues/helpers"; +import { IIssueFilterOptions } from "store/issues/types"; +import { IIssueState, IIssueLabel } from "types/issue"; + +type Props = { + filters: IIssueFilterOptions; + handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + labels?: IIssueLabel[] | undefined; + states?: IIssueState[] | undefined; +}; + +export const FilterSelection: React.FC = observer((props) => { + const { filters, handleFilters, layoutDisplayFiltersOptions, states } = props; + + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* priority */} + {isFilterEnabled("priority") && ( +
+ handleFilters("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* state */} + {isFilterEnabled("state") && ( +
+ handleFilters("state", val)} + searchQuery={filtersSearchQuery} + states={states} + /> +
+ )} + + {/* labels */} + {/* {isFilterEnabled("labels") && ( +
+ handleFilters("labels", val)} + labels={labels} + searchQuery={filtersSearchQuery} + /> +
+ )} */} +
+
+ ); +}); diff --git a/space/components/issues/filters/state.tsx b/space/components/issues/filters/state.tsx new file mode 100644 index 000000000..1175a5ed6 --- /dev/null +++ b/space/components/issues/filters/state.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +// components +import { FilterHeader, FilterOption } from "./helpers"; +// ui +import { Loader, StateGroupIcon } from "@plane/ui"; +// types +import { IIssueState } from "types/issue"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; + states: IIssueState[] | undefined; +}; + +export const FilterState: React.FC = (props) => { + const { appliedFilters, handleUpdate, searchQuery, states } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((state) => ( + handleUpdate(state.id)} + icon={} + title={state.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}; diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 03f082f33..1aa19bb3a 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -1,21 +1,23 @@ import { useEffect } from "react"; import Link from "next/link"; -import Image from "next/image"; import { useRouter } from "next/router"; // mobx import { observer } from "mobx-react-lite"; // components -import { NavbarSearch } from "./search"; +// import { NavbarSearch } from "./search"; import { NavbarIssueBoardView } from "./issue-board-view"; import { NavbarTheme } from "./theme"; +import { IssueFiltersDropdown } from "components/issues/filters"; // ui -import { PrimaryButton } from "components/ui"; +import { Avatar, Button } from "@plane/ui"; +import { Briefcase } from "lucide-react"; // lib import { useMobxStore } from "lib/mobx/store-provider"; // store import { RootStore } from "store/root"; +import { TIssueBoardKeys } from "types/issue"; const renderEmoji = (emoji: string | { name: string; color: string }) => { if (!emoji) return; @@ -30,10 +32,22 @@ const renderEmoji = (emoji: string | { name: string; color: string }) => { }; const IssueNavbar = observer(() => { - const { project: projectStore, user: userStore }: RootStore = useMobxStore(); + const { + project: projectStore, + user: userStore, + issuesFilter: { updateFilters }, + }: RootStore = useMobxStore(); // router const router = useRouter(); - const { workspace_slug, project_slug, board } = router.query; + const { workspace_slug, project_slug, board, peekId, states, priorities, labels } = router.query as { + workspace_slug: string; + project_slug: string; + peekId: string; + board: string; + states: string; + priorities: string; + labels: string; + }; const user = userStore?.currentUser; @@ -46,7 +60,7 @@ const IssueNavbar = observer(() => { useEffect(() => { if (workspace_slug && project_slug && projectStore?.deploySettings) { const viewsAcceptable: string[] = []; - let currentBoard: string | null = null; + let currentBoard: TIssueBoardKeys | null = null; if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list"); if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); @@ -56,85 +70,112 @@ const IssueNavbar = observer(() => { if (board) { if (viewsAcceptable.includes(board.toString())) { - currentBoard = board.toString(); + currentBoard = board.toString() as TIssueBoardKeys; } else { if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0]; + currentBoard = viewsAcceptable[0] as TIssueBoardKeys; } } } else { if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0]; + currentBoard = viewsAcceptable[0] as TIssueBoardKeys; } } if (currentBoard) { if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) { + let params: any = { board: currentBoard }; + if (peekId && peekId.length > 0) params = { ...params, peekId: peekId }; + if (priorities && priorities.length > 0) params = { ...params, priorities: priorities }; + if (states && states.length > 0) params = { ...params, states: states }; + if (labels && labels.length > 0) params = { ...params, labels: labels }; + + let storeParams: any = {}; + if (priorities && priorities.length > 0) storeParams = { ...storeParams, priority: priorities.split(",") }; + if (states && states.length > 0) storeParams = { ...storeParams, state: states.split(",") }; + if (labels && labels.length > 0) storeParams = { ...storeParams, labels: labels.split(",") }; + + if (storeParams) updateFilters(project_slug, storeParams); + projectStore.setActiveBoard(currentBoard); router.push({ pathname: `/${workspace_slug}/${project_slug}`, - query: { - board: currentBoard, - }, + query: { ...params }, }); } } } - }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]); + }, [ + board, + workspace_slug, + project_slug, + router, + projectStore, + projectStore?.deploySettings, + updateFilters, + labels, + states, + priorities, + peekId, + ]); return ( -
+
{/* project detail */} -
-
- {projectStore?.project && projectStore?.project?.emoji ? ( - renderEmoji(projectStore?.project?.emoji) +
+
+ {projectStore.project ? ( + projectStore.project?.emoji ? ( + + {renderEmoji(projectStore.project.emoji)} + + ) : projectStore.project?.icon_prop ? ( +
+ {renderEmoji(projectStore.project.icon_prop)} +
+ ) : ( + + {projectStore.project?.name.charAt(0)} + + ) ) : ( - + + + )}
-
+
{projectStore?.project?.name || `...`}
{/* issue search bar */} -
- -
+
{/* */}
{/* issue views */} -
+
+ {/* issue filters */} +
+ +
+ {/* theming */} -
+
{user ? ( -
- {user.avatar && user.avatar !== "" ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {user.display_name -
- ) : ( -
- {(user.display_name ?? "A")[0]} -
- )} +
+
{user.display_name}
) : (
- - - - Sign in - - + +
)} diff --git a/space/components/issues/navbar/issue-board-view.tsx b/space/components/issues/navbar/issue-board-view.tsx index 0ae71e8ee..906d3543d 100644 --- a/space/components/issues/navbar/issue-board-view.tsx +++ b/space/components/issues/navbar/issue-board-view.tsx @@ -5,30 +5,33 @@ import { issueViews } from "constants/data"; // mobx import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; +import { TIssueBoardKeys } from "types/issue"; export const NavbarIssueBoardView = observer(() => { - const { project: projectStore, issue: issueStore }: RootStore = useMobxStore(); - + const { + project: { viewOptions, setActiveBoard, activeBoard }, + }: RootStore = useMobxStore(); + // router const router = useRouter(); const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; const handleCurrentBoardView = (boardView: string) => { - projectStore.setActiveBoard(boardView); + setActiveBoard(boardView as TIssueBoardKeys); router.push(`/${workspace_slug}/${project_slug}?board=${boardView}`); }; return ( <> - {projectStore?.viewOptions && - Object.keys(projectStore?.viewOptions).map((viewKey: string) => { - if (projectStore?.viewOptions[viewKey]) { + {viewOptions && + Object.keys(viewOptions).map((viewKey: string) => { + if (viewOptions[viewKey]) { return (
handleCurrentBoardView(viewKey)} title={viewKey} diff --git a/space/components/issues/navbar/issue-filter.tsx b/space/components/issues/navbar/issue-filter.tsx deleted file mode 100644 index 83d5159d6..000000000 --- a/space/components/issues/navbar/issue-filter.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { ChevronDown } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; -// components -import { Dropdown } from "components/ui/dropdown"; -// constants -import { issueGroupFilter } from "constants/data"; - -const PRIORITIES = ["urgent", "high", "medium", "low"]; - -export const NavbarIssueFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const pathName = router.asPath; - - const handleOnSelect = (key: "states" | "labels" | "priorities", value: string) => { - // if (key === "states") { - // store.issue.userSelectedStates = store.issue.userSelectedStates.includes(value) - // ? store.issue.userSelectedStates.filter((s) => s !== value) - // : [...store.issue.userSelectedStates, value]; - // } else if (key === "labels") { - // store.issue.userSelectedLabels = store.issue.userSelectedLabels.includes(value) - // ? store.issue.userSelectedLabels.filter((l) => l !== value) - // : [...store.issue.userSelectedLabels, value]; - // } else if (key === "priorities") { - // store.issue.userSelectedPriorities = store.issue.userSelectedPriorities.includes(value) - // ? store.issue.userSelectedPriorities.filter((p) => p !== value) - // : [...store.issue.userSelectedPriorities, value]; - // } - // const paramsCommaSeparated = `${`board=${store.issue.currentIssueBoardView || "list"}`}${ - // store.issue.userSelectedPriorities.length > 0 ? `&priorities=${store.issue.userSelectedPriorities.join(",")}` : "" - // }${store.issue.userSelectedStates.length > 0 ? `&states=${store.issue.userSelectedStates.join(",")}` : ""}${ - // store.issue.userSelectedLabels.length > 0 ? `&labels=${store.issue.userSelectedLabels.join(",")}` : "" - // }`; - // router.replace(`${pathName}?${paramsCommaSeparated}`); - }; - - return ( - - Filters -