diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml
index 3d42f2ecd..301fd8766 100644
--- a/.github/workflows/build-aio-base.yml
+++ b/.github/workflows/build-aio-base.yml
@@ -2,6 +2,11 @@ name: Build AIO Base Image
on:
workflow_dispatch:
+ inputs:
+ base_tag_name:
+ description: 'Base Tag Name'
+ required: false
+ default: ''
env:
TARGET_BRANCH: ${{ github.ref_name }}
@@ -16,37 +21,46 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
- build_base: ${{ steps.changed_files.outputs.base_any_changed }}
+ image_tag: ${{ steps.set_env_variables.outputs.IMAGE_TAG }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
- echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
- echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
- echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
- echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
+ if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
+ echo "IMAGE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
+ elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
+ echo "IMAGE_TAG=latest" >> $GITHUB_OUTPUT
+ elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
+ echo "IMAGE_TAG=preview" >> $GITHUB_OUTPUT
+ else
+ echo "IMAGE_TAG=develop" >> $GITHUB_OUTPUT
+ fi
+
+
+ if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
+ echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
+ echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
+ echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
+ echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
+ else
+ echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
+ echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
+ echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
+ echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
+ fi
+
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- - name: Get changed files
- id: changed_files
- uses: tj-actions/changed-files@v42
- with:
- files_yaml: |
- base:
- - aio/Dockerfile.base
-
- base_build_push:
- if: ${{ needs.base_build_setup.outputs.build_base == 'true' || github.event_name == 'workflow_dispatch' || needs.base_build_setup.outputs.gh_branch_name == 'master' }}
+ full_base_build_push:
runs-on: ubuntu-latest
needs: [base_build_setup]
env:
- BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }}
- TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }}
+ BASE_IMG_TAG: makeplane/plane-aio-base:full-${{ needs.base_build_setup.outputs.image_tag }}
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
@@ -55,15 +69,6 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v4
- - name: Set Docker Tag
- run: |
- if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
- TAG=makeplane/plane-aio-base:latest
- else
- TAG=${{ env.BASE_IMG_TAG }}
- fi
- echo "BASE_IMG_TAG=${TAG}" >> $GITHUB_ENV
-
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -81,10 +86,53 @@ jobs:
uses: docker/build-push-action@v5.1.0
with:
context: ./aio
- file: ./aio/Dockerfile.base
+ file: ./aio/Dockerfile-base-full
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.BASE_IMG_TAG }}
push: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ env:
+ DOCKER_BUILDKIT: 1
+ DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ slim_base_build_push:
+ runs-on: ubuntu-latest
+ needs: [base_build_setup]
+ env:
+ BASE_IMG_TAG: makeplane/plane-aio-base:slim-${{ needs.base_build_setup.outputs.image_tag }}
+ BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
+ BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
+ BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
+ BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v4
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: ${{ env.BUILDX_DRIVER }}
+ version: ${{ env.BUILDX_VERSION }}
+ endpoint: ${{ env.BUILDX_ENDPOINT }}
+
+ - name: Build and Push to Docker Hub
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: ./aio
+ file: ./aio/Dockerfile-base-slim
+ platforms: ${{ env.BUILDX_PLATFORMS }}
+ tags: ${{ env.BASE_IMG_TAG }}
+ push: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
diff --git a/.github/workflows/build-aio-branch.yml b/.github/workflows/build-aio-branch.yml
new file mode 100644
index 000000000..de68d4b96
--- /dev/null
+++ b/.github/workflows/build-aio-branch.yml
@@ -0,0 +1,203 @@
+name: Branch Build AIO
+
+on:
+ workflow_dispatch:
+ inputs:
+ full:
+ description: 'Run full build'
+ type: boolean
+ required: false
+ default: false
+ slim:
+ description: 'Run slim build'
+ type: boolean
+ required: false
+ default: false
+ base_tag_name:
+ description: 'Base Tag Name'
+ required: false
+ default: ''
+ release:
+ types: [released, prereleased]
+
+env:
+ TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
+ FULL_BUILD_INPUT: ${{ github.event.inputs.full }}
+ SLIM_BUILD_INPUT: ${{ github.event.inputs.slim }}
+
+jobs:
+ branch_build_setup:
+ name: Build Setup
+ runs-on: ubuntu-latest
+ outputs:
+ gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
+ gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
+ gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
+ gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
+ gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
+ aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }}
+ do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }}
+ do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }}
+
+ steps:
+ - id: set_env_variables
+ name: Set Environment Variables
+ run: |
+ if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
+ echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
+ echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
+ echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
+ echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
+
+ echo "AIO_BASE_TAG=latest" >> $GITHUB_OUTPUT
+ else
+ echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
+ echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
+ echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
+ echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
+
+ if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
+ echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
+ elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
+ echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
+ else
+ echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT
+ fi
+ fi
+ echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
+
+ if [ "${{ env.FULL_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
+ echo "DO_FULL_BUILD=true" >> $GITHUB_OUTPUT
+ else
+ echo "DO_FULL_BUILD=false" >> $GITHUB_OUTPUT
+ fi
+
+ if [ "${{ env.SLIM_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
+ echo "DO_SLIM_BUILD=true" >> $GITHUB_OUTPUT
+ else
+ echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
+ fi
+
+ - id: checkout_files
+ name: Checkout Files
+ uses: actions/checkout@v4
+
+ full_build_push:
+ if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
+ runs-on: ubuntu-20.04
+ needs: [branch_build_setup]
+ env:
+ BUILD_TYPE: full
+ AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
+ AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
+ BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
+ BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
+ BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
+ steps:
+ - name: Set Docker Tag
+ run: |
+ if [ "${{ github.event_name }}" == "release" ]; then
+ TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
+ elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
+ TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
+ else
+ TAG=${{ env.AIO_IMAGE_TAGS }}
+ fi
+ echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: ${{ env.BUILDX_DRIVER }}
+ version: ${{ env.BUILDX_VERSION }}
+ endpoint: ${{ env.BUILDX_ENDPOINT }}
+
+ - name: Check out the repo
+ uses: actions/checkout@v4
+
+ - name: Build and Push to Docker Hub
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: .
+ file: ./aio/Dockerfile-app
+ platforms: ${{ env.BUILDX_PLATFORMS }}
+ tags: ${{ env.AIO_IMAGE_TAGS }}
+ push: true
+ build-args: |
+ BUILD_TAG=${{ env.AIO_BASE_TAG }}
+ BUILD_TYPE=${{env.BUILD_TYPE}}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ env:
+ DOCKER_BUILDKIT: 1
+ DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ slim_build_push:
+ if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
+ runs-on: ubuntu-20.04
+ needs: [branch_build_setup]
+ env:
+ BUILD_TYPE: slim
+ AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
+ AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
+ BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
+ BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
+ BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
+ BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
+ steps:
+ - name: Set Docker Tag
+ run: |
+ if [ "${{ github.event_name }}" == "release" ]; then
+ TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
+ elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
+ TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
+ else
+ TAG=${{ env.AIO_IMAGE_TAGS }}
+ fi
+ echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: ${{ env.BUILDX_DRIVER }}
+ version: ${{ env.BUILDX_VERSION }}
+ endpoint: ${{ env.BUILDX_ENDPOINT }}
+
+ - name: Check out the repo
+ uses: actions/checkout@v4
+
+ - name: Build and Push to Docker Hub
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: .
+ file: ./aio/Dockerfile-app
+ platforms: ${{ env.BUILDX_PLATFORMS }}
+ tags: ${{ env.AIO_IMAGE_TAGS }}
+ push: true
+ build-args: |
+ BUILD_TAG=${{ env.AIO_BASE_TAG }}
+ BUILD_TYPE=${{env.BUILD_TYPE}}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ env:
+ DOCKER_BUILDKIT: 1
+ DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml
index 5b94b215a..2e6f9c642 100644
--- a/.github/workflows/build-test-pull-request.yml
+++ b/.github/workflows/build-test-pull-request.yml
@@ -3,10 +3,11 @@ name: Build and Lint on Pull Request
on:
workflow_dispatch:
pull_request:
- types: ["opened", "synchronize"]
+ types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
+ if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml
index c195f8423..24c4fb995 100644
--- a/.github/workflows/create-sync-pr.yml
+++ b/.github/workflows/create-sync-pr.yml
@@ -29,7 +29,7 @@ jobs:
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
- Auto_Merge:
+ Create_PR:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml
index e848dc36d..0c71564e1 100644
--- a/.github/workflows/feature-deployment.yml
+++ b/.github/workflows/feature-deployment.yml
@@ -3,189 +3,108 @@ name: Feature Preview
on:
workflow_dispatch:
inputs:
- web-build:
+ base_tag_name:
+ description: 'Base Tag Name'
required: false
- description: "Build Web"
- type: boolean
- default: true
- space-build:
- required: false
- description: "Build Space"
- type: boolean
- default: false
- admin-build:
- required: false
- description: "Build Admin"
- type: boolean
- default: false
+ default: 'preview'
env:
- BUILD_WEB: ${{ github.event.inputs.web-build }}
- BUILD_SPACE: ${{ github.event.inputs.space-build }}
- BUILD_ADMIN: ${{ github.event.inputs.admin-build }}
+ TARGET_BRANCH: ${{ github.ref_name }}
jobs:
- setup-feature-build:
- name: Feature Build Setup
+ branch_build_setup:
+ name: Build Setup
runs-on: ubuntu-latest
- steps:
- - name: Checkout
- run: |
- echo "BUILD_WEB=$BUILD_WEB"
- echo "BUILD_SPACE=$BUILD_SPACE"
- echo "BUILD_ADMIN=$BUILD_ADMIN"
outputs:
- web-build: ${{ env.BUILD_WEB}}
- space-build: ${{env.BUILD_SPACE}}
- admin-build: ${{env.BUILD_ADMIN}}
+ gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
+ flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
+ gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
+ gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
+ gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
+ gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
+ aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }}
+ do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }}
+ do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }}
- feature-build-web:
- if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }}
- needs: setup-feature-build
- name: Feature Build Web
- runs-on: ubuntu-latest
- env:
- AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
- NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
steps:
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "18"
- - name: Install AWS cli
+ - id: set_env_variables
+ name: Set Environment Variables
run: |
- sudo apt-get update
- sudo apt-get install -y python3-pip
- pip3 install awscli
- - name: Checkout
+ echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
+ echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
+ echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
+ echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
+
+ if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
+ echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
+ else
+ echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT
+ fi
+
+ echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
+
+ FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
+ echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
+
+ - id: checkout_files
+ name: Checkout Files
uses: actions/checkout@v4
- with:
- path: plane
- - name: Install Dependencies
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn install
- - name: Build Web
- id: build-web
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn build --filter=web
- cd $GITHUB_WORKSPACE
- TAR_NAME="web.tar.gz"
- tar -czf $TAR_NAME ./plane
-
- FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
- aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
-
- feature-build-space:
- if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }}
- needs: setup-feature-build
- name: Feature Build Space
- runs-on: ubuntu-latest
+ full_build_push:
+ runs-on: ubuntu-20.04
+ needs: [branch_build_setup]
env:
- AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
- NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces"
- NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
+ BUILD_TYPE: full
+ AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
+ AIO_IMAGE_TAGS: makeplane/plane-aio-feature:${{ needs.branch_build_setup.outputs.flat_branch_name }}
+ BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
+ BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
+ BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
+ BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
+ steps:
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ driver: ${{ env.BUILDX_DRIVER }}
+ version: ${{ env.BUILDX_VERSION }}
+ endpoint: ${{ env.BUILDX_ENDPOINT }}
+
+ - name: Check out the repo
+ uses: actions/checkout@v4
+
+ - name: Build and Push to Docker Hub
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: .
+ file: ./aio/Dockerfile-app
+ platforms: ${{ env.BUILDX_PLATFORMS }}
+ tags: ${{ env.AIO_IMAGE_TAGS }}
+ push: true
+ build-args:
+ BUILD_TAG=${{ env.AIO_BASE_TAG }}
+ BUILD_TYPE=${{env.BUILD_TYPE}}
+ # cache-from: type=gha
+ # cache-to: type=gha,mode=max
+ env:
+ DOCKER_BUILDKIT: 1
+ DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
outputs:
- do-build: ${{ needs.setup-feature-build.outputs.space-build }}
- s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }}
- steps:
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "18"
- - name: Install AWS cli
- run: |
- sudo apt-get update
- sudo apt-get install -y python3-pip
- pip3 install awscli
- - name: Checkout
- uses: actions/checkout@v4
- with:
- path: plane
- - name: Install Dependencies
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn install
- - name: Build Space
- id: build-space
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn build --filter=space
- cd $GITHUB_WORKSPACE
-
- TAR_NAME="space.tar.gz"
- tar -czf $TAR_NAME ./plane
-
- FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
- aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
-
- feature-build-admin:
- if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }}
- needs: setup-feature-build
- name: Feature Build Admin
- runs-on: ubuntu-latest
- env:
- AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
- NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode"
- NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }}
- outputs:
- do-build: ${{ needs.setup-feature-build.outputs.admin-build }}
- s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }}
- steps:
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "18"
- - name: Install AWS cli
- run: |
- sudo apt-get update
- sudo apt-get install -y python3-pip
- pip3 install awscli
- - name: Checkout
- uses: actions/checkout@v4
- with:
- path: plane
- - name: Install Dependencies
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn install
- - name: Build Admin
- id: build-admin
- run: |
- cd $GITHUB_WORKSPACE/plane
- yarn build --filter=admin
- cd $GITHUB_WORKSPACE
-
- TAR_NAME="admin.tar.gz"
- tar -czf $TAR_NAME ./plane
-
- FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ")
- aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY
+ AIO_IMAGE_TAGS: ${{ env.AIO_IMAGE_TAGS }}
feature-deploy:
- if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }}
- needs:
- [
- setup-feature-build,
- feature-build-web,
- feature-build-space,
- feature-build-admin,
- ]
+ needs: [branch_build_setup, full_build_push]
name: Feature Deploy
runs-on: ubuntu-latest
env:
- AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }}
- AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}
KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }}
+ DEPLOYMENT_NAME: ${{ needs.branch_build_setup.outputs.flat_branch_name }}
steps:
- name: Install AWS cli
run: |
@@ -213,54 +132,37 @@ jobs:
./get_helm.sh
- name: App Deploy
run: |
- WEB_S3_URL=""
- if [ ${{ env.BUILD_WEB }} == true ]; then
- WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600)
- fi
+ helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
- SPACE_S3_URL=""
- if [ ${{ env.BUILD_SPACE }} == true ]; then
- SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600)
- fi
+ APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
- ADMIN_S3_URL=""
- if [ ${{ env.BUILD_ADMIN }} == true ]; then
- ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600)
- fi
+ helm --kube-insecure-skip-tls-verify uninstall \
+ ${{ env.DEPLOYMENT_NAME }} \
+ --namespace $APP_NAMESPACE \
+ --timeout 10m0s \
+ --wait \
+ --ignore-not-found
- if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then
+ METADATA=$(helm --kube-insecure-skip-tls-verify upgrade \
+ --install=true \
+ --namespace $APP_NAMESPACE \
+ --set dockerhub.loginid=${{ secrets.DOCKERHUB_USERNAME }} \
+ --set dockerhub.password=${{ secrets.DOCKERHUB_TOKEN_RO}} \
+ --set config.feature_branch=${{ env.DEPLOYMENT_NAME }} \
+ --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
+ --set ingress.tls_secret=${{vars.FEATURE_PREVIEW_INGRESS_TLS_SECRET || '' }} \
+ --output json \
+ --timeout 10m0s \
+ --wait \
+ ${{ env.DEPLOYMENT_NAME }} feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} )
- helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }}
+ APP_NAME=$(echo $METADATA | jq -r '.name')
- APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}"
- DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}"
+ INGRESS_HOSTNAME=$(kubectl get ingress -n $APP_NAMESPACE --insecure-skip-tls-verify \
+ -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
+ jq -r '.spec.rules[0].host')
- METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \
- --generate-name \
- --namespace $APP_NAMESPACE \
- --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \
- --set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
- --set web.enabled=${{ env.BUILD_WEB || false }} \
- --set web.artifact_url=$WEB_S3_URL \
- --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
- --set space.enabled=${{ env.BUILD_SPACE || false }} \
- --set space.artifact_url=$SPACE_S3_URL \
- --set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \
- --set admin.enabled=${{ env.BUILD_ADMIN || false }} \
- --set admin.artifact_url=$ADMIN_S3_URL \
- --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \
- --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \
- --output json \
- --timeout 1000s)
-
- APP_NAME=$(echo $METADATA | jq -r '.name')
-
- INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \
- -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \
- jq -r '.spec.rules[0].host')
-
- echo "****************************************"
- echo "APP NAME ::: $APP_NAME"
- echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
- echo "****************************************"
- fi
+ echo "****************************************"
+ echo "APP NAME ::: $APP_NAME"
+ echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME"
+ echo "****************************************"
diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx
index cec5c0748..510566e80 100644
--- a/admin/app/ai/form.tsx
+++ b/admin/app/ai/form.tsx
@@ -1,3 +1,4 @@
+"use client";
import { FC } from "react";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx
index 0a0bacac1..d461a626a 100644
--- a/admin/app/ai/layout.tsx
+++ b/admin/app/ai/layout.tsx
@@ -3,7 +3,7 @@ import { Metadata } from "next";
import { AdminLayout } from "@/layouts/admin-layout";
export const metadata: Metadata = {
- title: "AI Settings - God Mode",
+ title: "Artificial Intelligence Settings - Plane Web",
};
export default function AILayout({ children }: { children: ReactNode }) {
diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx
index a54ce6d8c..2a0747776 100644
--- a/admin/app/ai/page.tsx
+++ b/admin/app/ai/page.tsx
@@ -1,10 +1,8 @@
"use client";
-import { observer } from "mobx-react-lite";
+import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
-// components
-import { PageHeader } from "@/components/core";
// hooks
import { useInstance } from "@/hooks/store";
// components
@@ -18,7 +16,6 @@ const InstanceAIPage = observer(() => {
return (
<>
-
- We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "}
+ We will auto-generate this. Paste this into your{" "}
+
- We will auto-generate this. Paste this into your Authorized Redirect URI field. For this OAuth client{" "}
+ We will auto-generate this. Paste this into your {text}
- Changes you made will be lost if you go back. Do you
- wish to go back?
+ Changes you made will be lost if you go back. Do you wish to go back?
{description} {description} {errorData.message}
+ {{inviter_first_name}} has
+ invited you to work with them in
+ Â {{project_name}} in the workspace
+ {{workspace_name}} on Plane.
+
+ Despite our popularity, we
+ are humbly early-stage. We
+ are shipping fast, so please
+ reach out to us with feature
+ requests, major and minor
+ nits, and anything else you
+ find missing. We read every message, tweet, and conversation
+ and update our public roadmap.
+
+ This email was sent
+ to {{email }}. Please delete
+ if you aren't the intended
+ recipient.
+
+ You are back inside Plane
+ again.
+
+ Your account is reactivated and you
+ can now access your issues and other
+ data if the project is still active.
+ Alternatively, you can create a new
+ workspace and start adding projects
+ to it.
+
+ Despite our popularity, we
+ are humbly early-stage. We
+ are shipping fast, so please
+ reach out to us with feature
+ requests, major and minor
+ nits, and anything else you
+ find missing. We read every message, tweet, and conversation
+ and update our public roadmap.
+
+ This email was sent to
+ {{email}}. Please delete if
+ you aren't the intended
+ recipient.
+
+ You are out of Plane
+ now.
+
+ You have deactivated your account
+ successfully. Your issues, cycles,
+ and other project data will still be
+ accessible if the project is active
+ and there are other members in it.
+
+ Despite our popularity, we
+ are humbly early-stage. We
+ are shipping fast, so please
+ reach out to us with feature
+ requests, major and minor
+ nits, and anything else you
+ find missing. We read every message, tweet, and conversation
+ and update our public roadmap.
+
+ This email was sent to
+ {{email}}. Please delete if
+ you aren't the intended
+ recipient.
+ {item.title} LinkInputView tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
- return elem; // Return only if p tag is not empty
+ return elem; // Return only if p tag is not empty in td or th
}
+
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
@@ -115,7 +120,7 @@ function calcNodePos(pos: number, view: EditorView, node: Element) {
const $pos = view.state.doc.resolve(safePos);
if ($pos.depth > 1) {
- if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
+ if (node.matches("ul li, ol li")) {
// only for nested lists
const newPos = $pos.before($pos.depth);
return Math.max(0, Math.min(newPos, maxPos));
@@ -317,12 +322,24 @@ function DragHandle(options: DragHandleOptions) {
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
- // Li markers
- if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
- rect.left -= 18;
+ if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
+ if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
+ rect.left -= 5;
+ }
+ } else {
+ // Li markers
+ if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
+ rect.left -= 18;
+ }
}
+
if (node.matches(".table-wrapper")) {
rect.top += 8;
+ rect.left -= 8;
+ }
+
+ if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
+ rect.left += 8;
}
rect.width = options.dragHandleWidth;
@@ -357,6 +374,7 @@ function DragHandle(options: DragHandleOptions) {
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
+
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx
similarity index 92%
rename from packages/editor/core/src/ui/extensions/drop.tsx
rename to packages/editor/src/core/extensions/drop.tsx
index 4bf4e2625..d56f802d9 100644
--- a/packages/editor/core/src/ui/extensions/drop.tsx
+++ b/packages/editor/src/core/extensions/drop.tsx
@@ -1,7 +1,9 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
-import { UploadImage } from "src/types/upload-image";
-import { startImageUpload } from "src/ui/plugins/image/image-upload-handler";
+// plugins
+import { startImageUpload } from "@/plugins/image";
+// types
+import { UploadImage } from "@/types";
export const DropHandlerExtension = (uploadFile: UploadImage) =>
Extension.create({
diff --git a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/src/core/extensions/enter-key-extension.tsx
similarity index 81%
rename from packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx
rename to packages/editor/src/core/extensions/enter-key-extension.tsx
index 7d93bf36f..a01b58e59 100644
--- a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx
+++ b/packages/editor/src/core/extensions/enter-key-extension.tsx
@@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core";
-export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
+export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => void) =>
Extension.create({
name: "enterKey",
@@ -8,9 +8,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
return {
Enter: () => {
if (!this.editor.storage.mentionsOpen) {
- if (onEnterKeyPress) {
- onEnterKeyPress();
- }
+ onEnterKeyPress?.(this.editor.getHTML());
return true;
}
return false;
diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/src/core/extensions/extensions.tsx
similarity index 71%
rename from packages/editor/core/src/ui/extensions/index.tsx
rename to packages/editor/src/core/extensions/extensions.tsx
index 2507aca36..d1dfb4370 100644
--- a/packages/editor/core/src/ui/extensions/index.tsx
+++ b/packages/editor/src/core/extensions/extensions.tsx
@@ -1,36 +1,33 @@
+import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
-import Placeholder from "@tiptap/extension-placeholder";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
-
-import { Table } from "src/ui/extensions/table/table";
-import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
-import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
-import { TableRow } from "src/ui/extensions/table/table-row/table-row";
-
-import { ImageExtension } from "src/ui/extensions/image";
-
-import { isValidHttpUrl } from "src/lib/utils";
-import { Mentions } from "src/ui/mentions";
-
-import { CustomCodeBlockExtension } from "src/ui/extensions/code";
-import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
-import { CustomKeymap } from "src/ui/extensions/keymap";
-import { CustomQuoteExtension } from "src/ui/extensions/quote";
-
-import { DeleteImage } from "src/types/delete-image";
-import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
-import { RestoreImage } from "src/types/restore-image";
-import { CustomLinkExtension } from "src/ui/extensions/custom-link";
-import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
-import { CustomTypographyExtension } from "src/ui/extensions/typography";
-import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
-import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin";
-import { UploadImage } from "src/types/upload-image";
-import { DropHandlerExtension } from "src/ui/extensions/drop";
+// extensions
+import {
+ CustomCodeBlockExtension,
+ CustomCodeInlineExtension,
+ CustomCodeMarkPlugin,
+ CustomHorizontalRule,
+ CustomKeymap,
+ CustomLinkExtension,
+ CustomMention,
+ CustomQuoteExtension,
+ CustomTypographyExtension,
+ DropHandlerExtension,
+ ImageExtension,
+ ListKeymap,
+ Table,
+ TableCell,
+ TableHeader,
+ TableRow,
+} from "@/extensions";
+// helpers
+import { isValidHttpUrl } from "@/helpers/common";
+// types
+import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
type TArguments = {
mentionConfig: {
@@ -73,6 +70,7 @@ export const CoreEditorExtensions = ({
codeBlock: false,
horizontalRule: false,
blockquote: false,
+ history: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 1,
@@ -127,12 +125,13 @@ export const CoreEditorExtensions = ({
Markdown.configure({
html: true,
transformPastedText: true,
+ breaks: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
- Mentions({
+ CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts b/packages/editor/src/core/extensions/horizontal-rule.ts
similarity index 100%
rename from packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts
rename to packages/editor/src/core/extensions/horizontal-rule.ts
diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/src/core/extensions/image/extension.tsx
similarity index 66%
rename from packages/editor/core/src/ui/extensions/image/index.tsx
rename to packages/editor/src/core/extensions/image/extension.tsx
index 7ea12fb11..98961b7f0 100644
--- a/packages/editor/core/src/ui/extensions/image/index.tsx
+++ b/packages/editor/src/core/extensions/image/extension.tsx
@@ -1,20 +1,23 @@
-import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image";
import ImageExt from "@tiptap/extension-image";
-import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image";
-import { DeleteImage } from "src/types/delete-image";
-import { RestoreImage } from "src/types/restore-image";
-import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
-import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
-import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image";
-import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants";
-import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node";
+// helpers
+import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
+// plugins
+import {
+ IMAGE_NODE_TYPE,
+ ImageExtensionStorage,
+ TrackImageDeletionPlugin,
+ TrackImageRestorationPlugin,
+ UploadImagesPlugin,
+} from "@/plugins/image";
+// types
+import { DeleteImage, RestoreImage } from "@/types";
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend No matching results Colors will be adjusted to ensure sufficient contrast. Colors will be adjusted to ensure sufficient contrast. Colors will be adjusted to ensure sufficient contrast. Colors will be adjusted to ensure sufficient contrast.
- {label} {!required && "(optional)"}
+ {label}
")
+ return Response(
+ {
+ "response": text,
+ "response_html": text_html,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
(UNSPLASH_ACCESS_KEY,) = get_configuration_value(
diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py
index cc3a343d2..584edd8f9 100644
--- a/apiserver/plane/app/views/issue/archive.py
+++ b/apiserver/plane/app/views/issue/archive.py
@@ -2,52 +2,54 @@
import json
# Django imports
-from django.utils import timezone
-from django.db.models import (
- Prefetch,
- OuterRef,
- Func,
- F,
- Q,
- Case,
- Value,
- CharField,
- When,
- Exists,
- Max,
- UUIDField,
-)
from django.core.serializers.json import DjangoJSONEncoder
+from django.db.models import (
+ F,
+ Func,
+ OuterRef,
+ Q,
+ Prefetch,
+ Exists,
+)
+from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
-from django.contrib.postgres.aggregates import ArrayAgg
-from django.contrib.postgres.fields import ArrayField
-from django.db.models.functions import Coalesce
# Third Party imports
-from rest_framework.response import Response
from rest_framework import status
+from rest_framework.response import Response
-# Module imports
-from .. import BaseViewSet
-from plane.app.serializers import (
- IssueSerializer,
- IssueFlatSerializer,
- IssueDetailSerializer,
-)
from plane.app.permissions import (
ProjectEntityPermission,
)
+from plane.app.serializers import (
+ IssueFlatSerializer,
+ IssueSerializer,
+ IssueDetailSerializer
+)
+from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
- IssueLink,
IssueAttachment,
+ IssueLink,
IssueSubscriber,
IssueReaction,
)
-from plane.bgtasks.issue_activites_task import issue_activity
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
from plane.utils.issue_filters import issue_filters
-from plane.utils.user_timezone_converter import user_timezone_converter
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
+
+# Module imports
+from .. import BaseViewSet, BaseAPIView
+
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
)
@method_decorator(gzip_page)
@@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet):
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)
- # 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)
)
- if self.expand or self.fields:
- issues = IssueSerializer(
- issue_queryset,
- many=True,
- fields=self.fields,
- ).data
- else:
- issues = issue_queryset.values(
- "id",
- "name",
- "state_id",
- "sort_order",
- "completed_at",
- "estimate_point",
- "priority",
- "start_date",
- "target_date",
- "sequence_id",
- "project_id",
- "parent_id",
- "cycle_id",
- "module_ids",
- "label_ids",
- "assignee_ids",
- "sub_issues_count",
- "created_at",
- "updated_at",
- "created_by",
- "updated_by",
- "attachment_count",
- "link_count",
- "is_draft",
- "archived_at",
- )
- datetime_fields = ["created_at", "updated_at"]
- issues = user_timezone_converter(
- issues, datetime_fields, request.user.user_timezone
- )
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- return Response(issues, status=status.HTTP_200_OK)
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
+
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ # Check group and sub group value paginate
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ # group and sub group pagination
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ # Group Paginate
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ else:
+ # List Paginate
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
+ )
def retrieve(self, request, slug, project_id, pk=None):
issue = (
@@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet):
issue.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class BulkArchiveIssuesEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def post(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.objects.filter(
+ workspace__slug=slug, project_id=project_id, pk__in=issue_ids
+ ).select_related("state")
+ bulk_archive_issues = []
+ for issue in issues:
+ if issue.state.group not in ["completed", "cancelled"]:
+ return Response(
+ {
+ "error_code": 4091,
+ "error_message": "INVALID_ARCHIVE_STATE_GROUP"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ issue_activity.delay(
+ type="issue.activity.updated",
+ requested_data=json.dumps(
+ {
+ "archived_at": str(timezone.now().date()),
+ "automation": False,
+ }
+ ),
+ 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()),
+ notification=True,
+ origin=request.META.get("HTTP_ORIGIN"),
+ )
+ issue.archived_at = timezone.now().date()
+ bulk_archive_issues.append(issue)
+ Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
+
+ return Response(
+ {"archived_at": str(timezone.now().date())},
+ status=status.HTTP_200_OK,
+ )
diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py
index fad85b79d..e7b12a528 100644
--- a/apiserver/plane/app/views/issue/base.py
+++ b/apiserver/plane/app/views/issue/base.py
@@ -1,34 +1,30 @@
# Python imports
import json
+# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
- Case,
- CharField,
Exists,
F,
Func,
- Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
- When,
)
from django.db.models.functions import Coalesce
-
-# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
-from rest_framework import status
# Third Party imports
+from rest_framework import status
from rest_framework.response import Response
+# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@@ -49,11 +45,21 @@ from plane.db.models import (
IssueSubscriber,
Project,
)
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
from plane.utils.issue_filters import issue_filters
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
+from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
-from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
).distinct()
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 = queryset.filter(**filters)
+ # Issue queryset
+ issue_queryset, _ = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- # 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")
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
- # 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 = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
if self.fields or self.expand:
issues = IssueSerializer(
@@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
).distinct()
@method_decorator(gzip_page)
@@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
- priority_order = ["urgent", "high", "medium", "low", "none"]
- state_order = [
- "backlog",
- "unstarted",
- "started",
- "completed",
- "cancelled",
- ]
- # 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")
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- # 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(),
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
+
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
)
- ).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)
-
- # Only use serializer when expand or fields else return by values
- if self.expand or self.fields:
- issues = IssueSerializer(
- issue_queryset,
- many=True,
- fields=self.fields,
- expand=self.expand,
- ).data
- else:
- issues = issue_queryset.values(
- "id",
- "name",
- "state_id",
- "sort_order",
- "completed_at",
- "estimate_point",
- "priority",
- "start_date",
- "target_date",
- "sequence_id",
- "project_id",
- "parent_id",
- "cycle_id",
- "module_ids",
- "label_ids",
- "assignee_ids",
- "sub_issues_count",
- "created_at",
- "updated_at",
- "created_by",
- "updated_by",
- "attachment_count",
- "link_count",
- "is_draft",
- "archived_at",
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
)
- datetime_fields = ["created_at", "updated_at"]
- issues = user_timezone_converter(
- issues, datetime_fields, request.user.user_timezone
- )
- return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
- self.get_queryset()
- .filter(pk=serializer.data["id"])
+ issue_queryset_grouper(
+ queryset=self.get_queryset().filter(
+ pk=serializer.data["id"]
+ ),
+ group_by=None,
+ sub_group_by=None,
+ )
.values(
"id",
"name",
@@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet):
issue = (
self.get_queryset()
.filter(pk=pk)
+ .annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "labels__id",
+ distinct=True,
+ filter=~Q(labels__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ assignee_ids=Coalesce(
+ ArrayAgg(
+ "assignees__id",
+ distinct=True,
+ filter=~Q(assignees__id__isnull=True)
+ & Q(assignees__member_project__is_active=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ module_ids=Coalesce(
+ ArrayAgg(
+ "issue_module__module_id",
+ distinct=True,
+ filter=~Q(issue_module__module_id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ )
.prefetch_related(
Prefetch(
"issue_reactions",
diff --git a/apiserver/plane/app/views/issue/bulk_operations.py b/apiserver/plane/app/views/issue/bulk_operations.py
new file mode 100644
index 000000000..ea6637826
--- /dev/null
+++ b/apiserver/plane/app/views/issue/bulk_operations.py
@@ -0,0 +1,288 @@
+# Python imports
+import json
+from datetime import datetime
+
+# Django imports
+from django.utils import timezone
+
+# Third Party imports
+from rest_framework.response import Response
+from rest_framework import status
+
+# Module imports
+from .. import BaseAPIView
+from plane.app.permissions import (
+ ProjectEntityPermission,
+)
+from plane.db.models import (
+ Project,
+ Issue,
+ IssueLabel,
+ IssueAssignee,
+)
+from plane.bgtasks.issue_activites_task import issue_activity
+
+
+class BulkIssueOperationsEndpoint(BaseAPIView):
+ permission_classes = [
+ ProjectEntityPermission,
+ ]
+
+ def post(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,
+ )
+
+ # Get all the issues
+ issues = (
+ Issue.objects.filter(
+ workspace__slug=slug, project_id=project_id, pk__in=issue_ids
+ )
+ .select_related("state")
+ .prefetch_related("labels", "assignees")
+ )
+ # Current epoch
+ epoch = int(timezone.now().timestamp())
+
+ # Project details
+ project = Project.objects.get(workspace__slug=slug, pk=project_id)
+ workspace_id = project.workspace_id
+
+ # Initialize arrays
+ bulk_update_issues = []
+ bulk_issue_activities = []
+ bulk_update_issue_labels = []
+ bulk_update_issue_assignees = []
+
+ properties = request.data.get("properties", {})
+
+ if properties.get("start_date", False) and properties.get("target_date", False):
+ if (
+ datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
+ > datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
+ ):
+ return Response(
+ {
+ "error_code": 4100,
+ "error_message": "INVALID_ISSUE_DATES",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ for issue in issues:
+
+ # Priority
+ if properties.get("priority", False):
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"priority": properties.get("priority")}
+ ),
+ "current_instance": json.dumps(
+ {"priority": (issue.priority)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.priority = properties.get("priority")
+
+ # State
+ if properties.get("state_id", False):
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"state": properties.get("state")}
+ ),
+ "current_instance": json.dumps(
+ {"state": str(issue.state_id)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.state_id = properties.get("state_id")
+
+ # Start date
+ if properties.get("start_date", False):
+ if (
+ issue.target_date
+ and not properties.get("target_date", False)
+ and issue.target_date
+ <= datetime.strptime(
+ properties.get("start_date"), "%Y-%m-%d"
+ ).date()
+ ):
+ return Response(
+ {
+ "error_code": 4101,
+ "error_message": "INVALID_ISSUE_START_DATE",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"start_date": properties.get("start_date")}
+ ),
+ "current_instance": json.dumps(
+ {"start_date": str(issue.start_date)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.start_date = properties.get("start_date")
+
+ # Target date
+ if properties.get("target_date", False):
+ if (
+ issue.start_date
+ and not properties.get("start_date", False)
+ and issue.start_date
+ >= datetime.strptime(
+ properties.get("target_date"), "%Y-%m-%d"
+ ).date()
+ ):
+ return Response(
+ {
+ "error_code": 4102,
+ "error_message": "INVALID_ISSUE_TARGET_DATE",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"target_date": properties.get("target_date")}
+ ),
+ "current_instance": json.dumps(
+ {"target_date": str(issue.target_date)}
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+ issue.target_date = properties.get("target_date")
+
+ bulk_update_issues.append(issue)
+
+ # Labels
+ if properties.get("label_ids", []):
+ for label_id in properties.get("label_ids", []):
+ bulk_update_issue_labels.append(
+ IssueLabel(
+ issue=issue,
+ label_id=label_id,
+ created_by=request.user,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ )
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {"label_ids": properties.get("label_ids", [])}
+ ),
+ "current_instance": json.dumps(
+ {
+ "label_ids": [
+ str(label.id)
+ for label in issue.labels.all()
+ ]
+ }
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+
+ # Assignees
+ if properties.get("assignee_ids", []):
+ for assignee_id in properties.get(
+ "assignee_ids", issue.assignees
+ ):
+ bulk_update_issue_assignees.append(
+ IssueAssignee(
+ issue=issue,
+ assignee_id=assignee_id,
+ created_by=request.user,
+ project_id=project_id,
+ workspace_id=workspace_id,
+ )
+ )
+ bulk_issue_activities.append(
+ {
+ "type": "issue.activity.updated",
+ "requested_data": json.dumps(
+ {
+ "assignee_ids": properties.get(
+ "assignee_ids", []
+ )
+ }
+ ),
+ "current_instance": json.dumps(
+ {
+ "assignee_ids": [
+ str(assignee.id)
+ for assignee in issue.assignees.all()
+ ]
+ }
+ ),
+ "issue_id": str(issue.id),
+ "actor_id": str(request.user.id),
+ "project_id": str(project_id),
+ "epoch": epoch,
+ }
+ )
+
+ # Bulk update all the objects
+ Issue.objects.bulk_update(
+ bulk_update_issues,
+ [
+ "priority",
+ "start_date",
+ "target_date",
+ "state",
+ ],
+ batch_size=100,
+ )
+
+ # Create new labels
+ IssueLabel.objects.bulk_create(
+ bulk_update_issue_labels,
+ ignore_conflicts=True,
+ batch_size=100,
+ )
+
+ # Create new assignees
+ IssueAssignee.objects.bulk_create(
+ bulk_update_issue_assignees,
+ ignore_conflicts=True,
+ batch_size=100,
+ )
+ # update the issue activity
+ [
+ issue_activity.delay(**activity)
+ for activity in bulk_issue_activities
+ ]
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py
index 610c3c468..6944f40f7 100644
--- a/apiserver/plane/app/views/issue/draft.py
+++ b/apiserver/plane/app/views/issue/draft.py
@@ -6,18 +6,14 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
- Case,
- CharField,
Exists,
F,
Func,
- Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
- When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status
from rest_framework.response import Response
+# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueCreateSerializer,
@@ -44,10 +41,17 @@ from plane.db.models import (
IssueSubscriber,
Project,
)
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
from plane.utils.issue_filters import issue_filters
-from plane.utils.user_timezone_converter import user_timezone_converter
-
-# Module imports
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
from .. import BaseViewSet
@@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
).distinct()
@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)
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- # 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")
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
- # 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(),
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ # Check group and sub group value paginate
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ # group and sub group pagination
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ # Group Paginate
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
)
- ).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)
-
- # Only use serializer when expand else return by values
- if self.expand or self.fields:
- issues = IssueSerializer(
- issue_queryset,
- many=True,
- fields=self.fields,
- expand=self.expand,
- ).data
- else:
- issues = issue_queryset.values(
- "id",
- "name",
- "state_id",
- "sort_order",
- "completed_at",
- "estimate_point",
- "priority",
- "start_date",
- "target_date",
- "sequence_id",
- "project_id",
- "parent_id",
- "cycle_id",
- "module_ids",
- "label_ids",
- "assignee_ids",
- "sub_issues_count",
- "created_at",
- "updated_at",
- "created_by",
- "updated_by",
- "attachment_count",
- "link_count",
- "is_draft",
- "archived_at",
+ # List Paginate
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
)
- datetime_fields = ["created_at", "updated_at"]
- issues = user_timezone_converter(
- issues, datetime_fields, request.user.user_timezone
- )
- return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
+
issue = (
- self.get_queryset().filter(pk=serializer.data["id"]).first()
- )
- return Response(
- IssueSerializer(issue).data, status=status.HTTP_201_CREATED
+ issue_queryset_grouper(
+ queryset=self.get_queryset().filter(
+ pk=serializer.data["id"]
+ ),
+ group_by=None,
+ sub_group_by=None,
+ )
+ .values(
+ "id",
+ "name",
+ "state_id",
+ "sort_order",
+ "completed_at",
+ "estimate_point",
+ "priority",
+ "start_date",
+ "target_date",
+ "sequence_id",
+ "project_id",
+ "parent_id",
+ "cycle_id",
+ "module_ids",
+ "label_ids",
+ "assignee_ids",
+ "sub_issues_count",
+ "created_at",
+ "updated_at",
+ "created_by",
+ "updated_by",
+ "attachment_count",
+ "link_count",
+ "is_draft",
+ "archived_at",
+ )
+ .first()
)
+ return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
@@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet):
issue = (
self.get_queryset()
.filter(pk=pk)
+ .annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "labels__id",
+ distinct=True,
+ filter=~Q(labels__id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ assignee_ids=Coalesce(
+ ArrayAgg(
+ "assignees__id",
+ distinct=True,
+ filter=~Q(assignees__id__isnull=True)
+ & Q(assignees__member_project__is_active=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ module_ids=Coalesce(
+ ArrayAgg(
+ "issue_module__module_id",
+ distinct=True,
+ filter=~Q(issue_module__module_id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ )
.prefetch_related(
Prefetch(
"issue_reactions",
diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py
index 2ee4574eb..a010eff9b 100644
--- a/apiserver/plane/app/views/issue/sub_issue.py
+++ b/apiserver/plane/app/views/issue/sub_issue.py
@@ -99,6 +99,7 @@ class SubIssuesEndpoint(BaseAPIView):
),
)
.annotate(state_group=F("state__group"))
+ .order_by("-created_at")
)
# create's a dict with state group name with their respective issue id's
diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py
index 2cac5f366..8f6d9c6b0 100644
--- a/apiserver/plane/app/views/module/archive.py
+++ b/apiserver/plane/app/views/module/archive.py
@@ -12,8 +12,9 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
+ Sum
)
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, Cast
from django.utils import timezone
# Third party imports
@@ -25,7 +26,7 @@ from plane.app.permissions import (
from plane.app.serializers import (
ModuleDetailSerializer,
)
-from plane.db.models import Issue, Module, ModuleLink, UserFavorite
+from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
@@ -217,8 +218,118 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.values("count")
)
)
+
+ estimate_type = Project.objects.filter(
+ workspace__slug=slug,
+ pk=project_id,
+ estimate__isnull=False,
+ estimate__type="points",
+ ).exists()
+
+ data = ModuleDetailSerializer(queryset.first()).data
+ modules = queryset.first()
+
+ data["estimate_distribution"] = {}
+
+ if estimate_type:
+ label_distribution = (
+ Issue.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_estimates=Sum(
+ Cast("estimate_point__value", IntegerField())
+ ),
+ )
+ .annotate(
+ completed_estimates=Sum(
+ Cast("estimate_point__value", IntegerField()),
+ filter=Q(
+ completed_at__isnull=False,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .annotate(
+ pending_estimates=Sum(
+ Cast("estimate_point__value", IntegerField()),
+ filter=Q(
+ completed_at__isnull=True,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .order_by("first_name", "last_name")
+ )
+
+ assignee_distribution = (
+ Issue.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_estimates=Sum(
+ Cast("estimate_point__value", IntegerField())
+ ),
+ )
+ .annotate(
+ completed_estimates=Sum(
+ Cast("estimate_point__value", IntegerField()),
+ filter=Q(
+ completed_at__isnull=False,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .annotate(
+ pending_estimates=Sum(
+ Cast("estimate_point__value", IntegerField()),
+ filter=Q(
+ completed_at__isnull=True,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .order_by("label_name")
+ )
+ data["estimate_distribution"]["assignee"] = assignee_distribution
+ data["estimate_distribution"]["label"] = label_distribution
+
+ if modules and modules.start_date and modules.target_date:
+ data["estimate_distribution"]["completion_chart"] = (
+ burndown_plot(
+ queryset=modules,
+ slug=slug,
+ project_id=project_id,
+ plot_type="points",
+ module_id=pk,
+ )
+ )
assignee_distribution = (
- Issue.objects.filter(
+ Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -268,7 +379,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
)
label_distribution = (
- Issue.objects.filter(
+ Issue.issue_objects.filter(
issue_module__module_id=pk,
workspace__slug=slug,
project_id=project_id,
@@ -309,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("label_name")
)
- data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
@@ -317,12 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
}
# Fetch the modules
- modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
+ plot_type="issues",
module_id=pk,
)
diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py
index 56267554d..084204595 100644
--- a/apiserver/plane/app/views/module/base.py
+++ b/apiserver/plane/app/views/module/base.py
@@ -16,8 +16,10 @@ from django.db.models import (
Subquery,
UUIDField,
Value,
+ Sum,
+ FloatField,
)
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, Cast
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
@@ -128,6 +130,90 @@ class ModuleViewSet(BaseViewSet):
.annotate(cnt=Count("pk"))
.values("cnt")
)
+ completed_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ state__group="completed",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ completed_estimate_points=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("completed_estimate_points")[:1]
+ )
+
+ total_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ total_estimate_points=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("total_estimate_points")[:1]
+ )
+ backlog_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ state__group="backlog",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ backlog_estimate_point=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("backlog_estimate_point")[:1]
+ )
+ unstarted_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ state__group="unstarted",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ unstarted_estimate_point=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("unstarted_estimate_point")[:1]
+ )
+ started_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ state__group="started",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ started_estimate_point=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("started_estimate_point")[:1]
+ )
+ cancelled_estimate_point = (
+ Issue.issue_objects.filter(
+ estimate_point__estimate__type="points",
+ state__group="cancelled",
+ issue_module__module_id=OuterRef("pk"),
+ )
+ .values("issue_module__module_id")
+ .annotate(
+ cancelled_estimate_point=Sum(
+ Cast("estimate_point__value", FloatField())
+ )
+ )
+ .values("cancelled_estimate_point")[:1]
+ )
return (
super()
.get_queryset()
@@ -182,6 +268,42 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()),
)
)
+ .annotate(
+ backlog_estimate_points=Coalesce(
+ Subquery(backlog_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
+ .annotate(
+ unstarted_estimate_points=Coalesce(
+ Subquery(unstarted_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
+ .annotate(
+ started_estimate_points=Coalesce(
+ Subquery(started_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
+ .annotate(
+ cancelled_estimate_points=Coalesce(
+ Subquery(cancelled_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
+ .annotate(
+ completed_estimate_points=Coalesce(
+ Subquery(completed_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
+ .annotate(
+ total_estimate_points=Coalesce(
+ Subquery(total_estimate_point),
+ Value(0, output_field=FloatField()),
+ ),
+ )
.annotate(
member_ids=Coalesce(
ArrayAgg(
@@ -233,6 +355,8 @@ class ModuleViewSet(BaseViewSet):
"total_issues",
"started_issues",
"unstarted_issues",
+ "completed_estimate_points",
+ "total_estimate_points",
"backlog_issues",
"created_at",
"updated_at",
@@ -284,6 +408,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
+ "completed_estimate_points",
+ "total_estimate_points",
"total_issues",
"is_favorite",
"cancelled_issues",
@@ -317,6 +443,116 @@ class ModuleViewSet(BaseViewSet):
)
)
+ estimate_type = Project.objects.filter(
+ workspace__slug=slug,
+ pk=project_id,
+ estimate__isnull=False,
+ estimate__type="points",
+ ).exists()
+
+ data = ModuleDetailSerializer(queryset.first()).data
+ modules = queryset.first()
+
+ data["estimate_distribution"] = {}
+
+ if estimate_type:
+ assignee_distribution = (
+ Issue.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_estimates=Sum(
+ Cast("estimate_point__value", FloatField())
+ ),
+ )
+ .annotate(
+ completed_estimates=Sum(
+ Cast("estimate_point__value", FloatField()),
+ filter=Q(
+ completed_at__isnull=False,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .annotate(
+ pending_estimates=Sum(
+ Cast("estimate_point__value", FloatField()),
+ filter=Q(
+ completed_at__isnull=True,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .order_by("first_name", "last_name")
+ )
+
+ label_distribution = (
+ Issue.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_estimates=Sum(
+ Cast("estimate_point__value", FloatField())
+ ),
+ )
+ .annotate(
+ completed_estimates=Sum(
+ Cast("estimate_point__value", FloatField()),
+ filter=Q(
+ completed_at__isnull=False,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .annotate(
+ pending_estimates=Sum(
+ Cast("estimate_point__value", FloatField()),
+ filter=Q(
+ completed_at__isnull=True,
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ )
+ .order_by("label_name")
+ )
+ data["estimate_distribution"]["assignees"] = assignee_distribution
+ data["estimate_distribution"]["labels"] = label_distribution
+
+ if modules and modules.start_date and modules.target_date:
+ data["estimate_distribution"]["completion_chart"] = (
+ burndown_plot(
+ queryset=modules,
+ slug=slug,
+ project_id=project_id,
+ plot_type="points",
+ module_id=pk,
+ )
+ )
+
assignee_distribution = (
Issue.objects.filter(
issue_module__module_id=pk,
@@ -342,7 +578,7 @@ class ModuleViewSet(BaseViewSet):
archived_at__isnull=True,
is_draft=False,
),
- )
+ ),
)
.annotate(
completed_issues=Count(
@@ -409,20 +645,17 @@ class ModuleViewSet(BaseViewSet):
.order_by("label_name")
)
- data = ModuleDetailSerializer(queryset.first()).data
data["distribution"] = {
"assignees": assignee_distribution,
"labels": label_distribution,
"completion_chart": {},
}
-
- # Fetch the modules
- modules = queryset.first()
if modules and modules.start_date and modules.target_date:
data["distribution"]["completion_chart"] = burndown_plot(
queryset=modules,
slug=slug,
project_id=project_id,
+ plot_type="issues",
module_id=pk,
)
@@ -469,6 +702,8 @@ class ModuleViewSet(BaseViewSet):
"external_id",
"logo_props",
# computed fields
+ "completed_estimate_points",
+ "total_estimate_points",
"is_favorite",
"cancelled_issues",
"completed_issues",
diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py
index 879ab7e47..53665b943 100644
--- a/apiserver/plane/app/views/module/issue.py
+++ b/apiserver/plane/app/views/module/issue.py
@@ -1,37 +1,50 @@
# Python imports
import json
+from django.db.models import (
+ F,
+ Func,
+ OuterRef,
+ Q,
+)
+
# Django Imports
from django.utils import timezone
-from django.db.models import F, OuterRef, Func, Q
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
-from django.contrib.postgres.aggregates import ArrayAgg
-from django.contrib.postgres.fields import ArrayField
-from django.db.models import Value, UUIDField
-from django.db.models.functions import Coalesce
# Third party imports
-from rest_framework.response import Response
from rest_framework import status
+from rest_framework.response import Response
+
+from plane.app.permissions import (
+ ProjectEntityPermission,
+)
+from plane.app.serializers import (
+ ModuleIssueSerializer,
+)
+from plane.bgtasks.issue_activites_task import issue_activity
+from plane.db.models import (
+ Issue,
+ IssueAttachment,
+ IssueLink,
+ ModuleIssue,
+ Project,
+)
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
+from plane.utils.issue_filters import issue_filters
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
# Module imports
from .. import BaseViewSet
-from plane.app.serializers import (
- ModuleIssueSerializer,
- IssueSerializer,
-)
-from plane.app.permissions import ProjectEntityPermission
-from plane.db.models import (
- ModuleIssue,
- Project,
- Issue,
- IssueLink,
- IssueAttachment,
-)
-from plane.bgtasks.issue_activites_task import issue_activity
-from plane.utils.issue_filters import issue_filters
-from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
@@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
).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
- ]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
- if self.fields or self.expand:
- issues = IssueSerializer(
- issue_queryset, many=True, fields=fields if fields else None
- ).data
- else:
- issues = issue_queryset.values(
- "id",
- "name",
- "state_id",
- "sort_order",
- "completed_at",
- "estimate_point",
- "priority",
- "start_date",
- "target_date",
- "sequence_id",
- "project_id",
- "parent_id",
- "cycle_id",
- "module_ids",
- "label_ids",
- "assignee_ids",
- "sub_issues_count",
- "created_at",
- "updated_at",
- "created_by",
- "updated_by",
- "attachment_count",
- "link_count",
- "is_draft",
- "archived_at",
- )
- datetime_fields = ["created_at", "updated_at"]
- issues = user_timezone_converter(
- issues, datetime_fields, request.user.user_timezone
- )
+ order_by_param = request.GET.get("order_by", "created_at")
- return Response(issues, status=status.HTTP_200_OK)
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
+
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
+
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ # Check group and sub group value paginate
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ # group and sub group pagination
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ # Group Paginate
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=project_id,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ else:
+ # List Paginate
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
+ )
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py
index 8dae618db..12997241c 100644
--- a/apiserver/plane/app/views/notification/base.py
+++ b/apiserver/plane/app/views/notification/base.py
@@ -1,26 +1,27 @@
# Django imports
-from django.db.models import Q, OuterRef, Exists
+from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
-from plane.utils.paginator import BasePaginator
-# Module imports
-from ..base import BaseViewSet, BaseAPIView
-from plane.db.models import (
- Notification,
- IssueAssignee,
- IssueSubscriber,
- Issue,
- WorkspaceMember,
- UserNotificationPreference,
-)
from plane.app.serializers import (
NotificationSerializer,
UserNotificationPreferenceSerializer,
)
+from plane.db.models import (
+ Issue,
+ IssueAssignee,
+ IssueSubscriber,
+ Notification,
+ UserNotificationPreference,
+ WorkspaceMember,
+)
+from plane.utils.paginator import BasePaginator
+
+# Module imports
+from ..base import BaseAPIView, BaseViewSet
class NotificationViewSet(BaseViewSet, BasePaginator):
@@ -42,13 +43,22 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Get query parameters
snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false")
- read = request.GET.get("read", "true")
+ read = request.GET.get("read", None)
type = request.GET.get("type", "all")
+ q_filters = Q()
+
+ inbox_issue = Issue.objects.filter(
+ pk=OuterRef("entity_identifier"),
+ issue_inbox__status__in=[0, 2, -2],
+ workspace__slug=self.kwargs.get("slug"),
+ )
notifications = (
Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
)
+ .filter(entity_name="issue")
+ .annotate(is_inbox_issue=Exists(inbox_issue))
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
@@ -73,8 +83,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
if read == "false":
notifications = notifications.filter(read_at__isnull=True)
+ if read == "true":
+ notifications = notifications.filter(read_at__isnull=False)
+
+ type = type.split(",")
# Subscribed issues
- if type == "watching":
+ if "subscribed" in type:
issue_ids = (
IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
@@ -96,41 +110,39 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.filter(created=False, assigned=False)
.values_list("issue_id", flat=True)
)
- notifications = notifications.filter(
- entity_identifier__in=issue_ids,
- )
+ q_filters |= Q(entity_identifier__in=issue_ids)
# Assigned Issues
- if type == "assigned":
+ if "assigned" in type:
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
- notifications = notifications.filter(
- entity_identifier__in=issue_ids
- )
+ q_filters |= Q(entity_identifier__in=issue_ids)
# Created issues
- if type == "created":
+ if "created" in type:
if WorkspaceMember.objects.filter(
workspace__slug=slug,
member=request.user,
role__lt=15,
is_active=True,
).exists():
- notifications = Notification.objects.none()
+ notifications = notifications.none()
else:
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
- notifications = notifications.filter(
- entity_identifier__in=issue_ids
- )
+ q_filters |= Q(entity_identifier__in=issue_ids)
+
+ # Apply the combined Q object filters
+ notifications = notifications.filter(q_filters)
# Pagination
if request.GET.get("per_page", False) and request.GET.get(
"cursor", False
):
return self.paginate(
+ order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(
@@ -198,43 +210,19 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug):
# Watching Issues Count
- watching_issues_count = Notification.objects.filter(
+ unread_notifications_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
- entity_identifier__in=IssueSubscriber.objects.filter(
- workspace__slug=slug, subscriber_id=request.user.id
- ).values_list("issue_id", flat=True),
- ).count()
-
- # My Issues Count
- my_issues_count = Notification.objects.filter(
- workspace__slug=slug,
- receiver_id=request.user.id,
- read_at__isnull=True,
- archived_at__isnull=True,
- entity_identifier__in=IssueAssignee.objects.filter(
- workspace__slug=slug, assignee_id=request.user.id
- ).values_list("issue_id", flat=True),
- ).count()
-
- # Created Issues Count
- created_issues_count = Notification.objects.filter(
- workspace__slug=slug,
- receiver_id=request.user.id,
- read_at__isnull=True,
- archived_at__isnull=True,
- entity_identifier__in=Issue.objects.filter(
- workspace__slug=slug, created_by=request.user
- ).values_list("pk", flat=True),
+ snoozed_till__isnull=True,
).count()
return Response(
{
- "watching_issues": watching_issues_count,
- "my_issues": my_issues_count,
- "created_issues": created_issues_count,
+ "total_unread_notifications_count": int(
+ unread_notifications_count
+ )
},
status=status.HTTP_200_OK,
)
diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index c7f53b9fe..60fb81eeb 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -6,15 +6,19 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
-from django.db.models import Exists, OuterRef, Q
+from django.db.models import Exists, OuterRef, Q, Value, UUIDField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
+from django.contrib.postgres.aggregates import ArrayAgg
+from django.contrib.postgres.fields import ArrayField
+from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
PageLogSerializer,
@@ -27,6 +31,7 @@ from plane.db.models import (
PageLog,
UserFavorite,
ProjectMember,
+ ProjectPage,
)
# Module imports
@@ -66,28 +71,53 @@ class PageViewSet(BaseViewSet):
user=self.request.user,
entity_type="page",
entity_identifier=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,
- project__project_projectmember__is_active=True,
- project__archived_at__isnull=True,
+ projects__project_projectmember__member=self.request.user,
+ projects__project_projectmember__is_active=True,
+ projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0))
- .select_related("project")
+ .prefetch_related("projects")
.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")
+ .annotate(
+ project=Exists(
+ ProjectPage.objects.filter(
+ page_id=OuterRef("id"),
+ project_id=self.kwargs.get("project_id"),
+ )
+ )
+ )
+ .annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "page_labels__label_id",
+ distinct=True,
+ filter=~Q(page_labels__label_id__isnull=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ project_ids=Coalesce(
+ ArrayAgg(
+ "projects__id",
+ distinct=True,
+ filter=~Q(projects__id=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ )
+ .filter(project=True)
.distinct()
)
@@ -107,7 +137,7 @@ class PageViewSet(BaseViewSet):
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
- page = Page.objects.get(pk=serializer.data["id"])
+ page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -115,7 +145,9 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk,
+ workspace__slug=slug,
+ projects__id=project_id,
)
if page.is_locked:
@@ -127,7 +159,9 @@ class PageViewSet(BaseViewSet):
parent = request.data.get("parent", None)
if parent:
_ = Page.objects.get(
- pk=parent, workspace__slug=slug, project_id=project_id
+ pk=parent,
+ workspace__slug=slug,
+ projects__id=project_id,
)
# Only update access if the page owner is the requesting user
@@ -187,7 +221,7 @@ class PageViewSet(BaseViewSet):
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = True
@@ -196,7 +230,7 @@ class PageViewSet(BaseViewSet):
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk, workspace__slug=slug, projects__id=project_id
).first()
page.is_locked = False
@@ -211,7 +245,7 @@ class PageViewSet(BaseViewSet):
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can archive the page
@@ -238,7 +272,7 @@ class PageViewSet(BaseViewSet):
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner or admin can un archive the page
@@ -267,7 +301,7 @@ class PageViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ pk=pk, workspace__slug=slug, projects__id=project_id
)
# only the owner and admin can delete the page
@@ -293,7 +327,7 @@ class PageViewSet(BaseViewSet):
# remove parent from all the children
_ = Page.objects.filter(
- parent_id=pk, project_id=project_id, workspace__slug=slug
+ parent_id=pk, projects__id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
@@ -380,7 +414,6 @@ class SubPagesEndpoint(BaseAPIView):
pages = (
PageLog.objects.filter(
page_id=page_id,
- project_id=project_id,
workspace__slug=slug,
entity_name__in=["forward_link", "back_link"],
)
@@ -398,8 +431,12 @@ class PagesDescriptionViewSet(BaseViewSet):
]
def retrieve(self, request, slug, project_id, pk):
- page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ page = (
+ Page.objects.filter(
+ pk=pk, workspace__slug=slug, projects__id=project_id
+ )
+ .filter(Q(owned_by=self.request.user) | Q(access=0))
+ .first()
)
binary_data = page.description_binary
@@ -418,10 +455,26 @@ class PagesDescriptionViewSet(BaseViewSet):
return response
def partial_update(self, request, slug, project_id, pk):
- page = Page.objects.get(
- pk=pk, workspace__slug=slug, project_id=project_id
+ page = (
+ Page.objects.filter(
+ pk=pk, workspace__slug=slug, projects__id=project_id
+ )
+ .filter(Q(owned_by=self.request.user) | Q(access=0))
+ .first()
)
+ if page.is_locked:
+ return Response(
+ {"error": "Page is locked"},
+ status=471,
+ )
+
+ if page.archived_at:
+ return Response(
+ {"error": "Page is archived"},
+ status=472,
+ )
+
base64_data = request.data.get("description_binary")
if base64_data:
diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py
index 39db11871..55471e674 100644
--- a/apiserver/plane/app/views/project/base.py
+++ b/apiserver/plane/app/views/project/base.py
@@ -1,26 +1,25 @@
# Python imports
import boto3
+from django.conf import settings
+from django.utils import timezone
import json
# Django imports
from django.db import IntegrityError
from django.db.models import (
- Prefetch,
- Q,
Exists,
- OuterRef,
F,
Func,
+ OuterRef,
+ Prefetch,
+ Q,
Subquery,
)
-from django.conf import settings
-from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
-from rest_framework import status
-from rest_framework import serializers
+from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
# Module imports
@@ -28,27 +27,26 @@ from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
- ProjectDeployBoardSerializer,
+ DeployBoardSerializer,
)
from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
)
-
from plane.db.models import (
- Project,
- ProjectMember,
- Workspace,
- State,
UserFavorite,
- ProjectIdentifier,
- Module,
Cycle,
Inbox,
- ProjectDeployBoard,
+ DeployBoard,
IssueProperty,
Issue,
+ Module,
+ Project,
+ ProjectIdentifier,
+ ProjectMember,
+ State,
+ Workspace,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
@@ -137,12 +135,11 @@ class ProjectViewSet(BaseViewSet):
).values("role")
)
.annotate(
- is_deployed=Exists(
- ProjectDeployBoard.objects.filter(
- project_id=OuterRef("pk"),
- workspace__slug=self.kwargs.get("slug"),
- )
- )
+ anchor=DeployBoard.objects.filter(
+ entity_name="project",
+ entity_identifier=OuterRef("pk"),
+ workspace__slug=self.kwargs.get("slug"),
+ ).values("anchor")
)
.annotate(sort_order=Subquery(sort_order))
.prefetch_related(
@@ -169,6 +166,7 @@ class ProjectViewSet(BaseViewSet):
"cursor", False
):
return self.paginate(
+ order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=(projects),
on_results=lambda projects: ProjectListSerializer(
@@ -242,6 +240,12 @@ class ProjectViewSet(BaseViewSet):
)
).first()
+ if project is None:
+ return Response(
+ {"error": "Project does not exist"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -639,29 +643,28 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
return Response(files, status=status.HTTP_200_OK)
-class ProjectDeployBoardViewSet(BaseViewSet):
+class DeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
- serializer_class = ProjectDeployBoardSerializer
- model = ProjectDeployBoard
+ serializer_class = DeployBoardSerializer
+ model = DeployBoard
- 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 list(self, request, slug, project_id):
+ project_deploy_board = DeployBoard.objects.filter(
+ entity_name="project",
+ entity_identifier=project_id,
+ workspace__slug=slug,
+ ).first()
+
+ serializer = DeployBoardSerializer(project_deploy_board)
+ return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
- comments = request.data.get("comments", False)
- reactions = request.data.get("reactions", False)
+ comments = request.data.get("is_comments_enabled", False)
+ reactions = request.data.get("is_reactions_enabled", False)
inbox = request.data.get("inbox", None)
- votes = request.data.get("votes", False)
+ votes = request.data.get("is_votes_enabled", False)
views = request.data.get(
"views",
{
@@ -673,17 +676,18 @@ class ProjectDeployBoardViewSet(BaseViewSet):
},
)
- project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
- anchor=f"{slug}/{project_id}",
+ project_deploy_board, _ = DeployBoard.objects.get_or_create(
+ entity_name="project",
+ entity_identifier=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.view_props = views
+ project_deploy_board.is_votes_enabled = votes
+ project_deploy_board.is_comments_enabled = comments
+ project_deploy_board.is_reactions_enabled = reactions
project_deploy_board.save()
- serializer = ProjectDeployBoardSerializer(project_deploy_board)
+ serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py
index 187dfc8d0..9ecb512d3 100644
--- a/apiserver/plane/app/views/project/member.py
+++ b/apiserver/plane/app/views/project/member.py
@@ -24,6 +24,8 @@ from plane.db.models import (
TeamMember,
IssueProperty,
)
+from plane.bgtasks.project_add_user_email_task import project_add_user_email
+from plane.utils.host import base_host
class ProjectMemberViewSet(BaseViewSet):
@@ -64,33 +66,29 @@ class ProjectMemberViewSet(BaseViewSet):
)
def create(self, request, slug, project_id):
+ # Get the list of members to be added to the project and their roles i.e. the user_id and the role
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
+ # Check if the members array is empty
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
+
+ # Initialize the bulk arrays
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")
- )
-
- bulk_project_members = []
+ # Create a dictionary of the member_id and their roles
member_roles = {
member.get("member_id"): member.get("role") for member in members
}
- # Update roles in the members array based on the member_roles dictionary
+
+ # Update roles in the members array based on the member_roles dictionary and set is_active to True
for project_member in ProjectMember.objects.filter(
project_id=project_id,
member_id__in=[member.get("member_id") for member in members],
@@ -104,13 +102,27 @@ class ProjectMemberViewSet(BaseViewSet):
bulk_project_members, ["is_active", "role"], batch_size=100
)
+ # Get the list of project members of the requested workspace with the given slug
+ 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")
+ )
+
+ # Loop through requested members
for member in members:
+
+ # Get the sort orders of the member
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"))
]
+ # Create a new project member
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
@@ -122,6 +134,7 @@ class ProjectMemberViewSet(BaseViewSet):
),
)
)
+ # Create a new issue property
bulk_issue_props.append(
IssueProperty(
user_id=member.get("member_id"),
@@ -130,6 +143,7 @@ class ProjectMemberViewSet(BaseViewSet):
)
)
+ # Bulk create the project members and issue properties
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
@@ -144,7 +158,18 @@ class ProjectMemberViewSet(BaseViewSet):
project_id=project_id,
member_id__in=[member.get("member_id") for member in members],
)
+ # Send emails to notify the users
+ [
+ project_add_user_email.delay(
+ base_host(request=request, is_app=True),
+ project_member.id,
+ request.user.id,
+ )
+ for project_member in project_members
+ ]
+ # Serialize the project members
serializer = ProjectMemberRoleSerializer(project_members, many=True)
+ # Return the serialized data
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search/base.py
similarity index 68%
rename from apiserver/plane/app/views/search.py
rename to apiserver/plane/app/views/search/base.py
index 93bab2de3..8a7b9d908 100644
--- a/apiserver/plane/app/views/search.py
+++ b/apiserver/plane/app/views/search/base.py
@@ -2,14 +2,17 @@
import re
# Django imports
-from django.db.models import Q
+from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField
+from django.contrib.postgres.aggregates import ArrayAgg
+from django.contrib.postgres.fields import ArrayField
+from django.db.models.functions import Coalesce
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
-from .base import BaseAPIView
+from plane.app.views.base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
@@ -18,8 +21,8 @@ from plane.db.models import (
Module,
Page,
IssueView,
+ ProjectPage,
)
-from plane.utils.issue_search import search_issues
class GlobalSearchEndpoint(BaseAPIView):
@@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
- pages = Page.objects.filter(
- q,
- project__project_projectmember__member=self.request.user,
- project__project_projectmember__is_active=True,
- project__archived_at__isnull=True,
- workspace__slug=slug,
+ pages = (
+ Page.objects.filter(
+ q,
+ projects__project_projectmember__member=self.request.user,
+ projects__project_projectmember__is_active=True,
+ projects__archived_at__isnull=True,
+ workspace__slug=slug,
+ )
+ .annotate(
+ project_ids=Coalesce(
+ ArrayAgg(
+ "projects__id",
+ distinct=True,
+ filter=~Q(projects__id=True),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ )
+ .annotate(
+ project_identifiers=Coalesce(
+ ArrayAgg(
+ "projects__identifier",
+ distinct=True,
+ filter=~Q(projects__id=True),
+ ),
+ Value([], output_field=ArrayField(CharField())),
+ ),
+ )
)
if workspace_search == "false" and project_id:
- pages = pages.filter(project_id=project_id)
+ project_subquery = ProjectPage.objects.filter(
+ page_id=OuterRef("id"),
+ project_id=project_id,
+ ).values_list("project_id", flat=True)[:1]
+
+ pages = pages.annotate(
+ project_id=Subquery(project_subquery)
+ ).filter(project_id=project_id)
return pages.distinct().values(
"name",
"id",
- "project_id",
- "project__identifier",
+ "project_ids",
+ "project_identifiers",
"workspace__slug",
)
@@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView):
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
-
-
-class IssueSearchEndpoint(BaseAPIView):
- def get(self, request, slug, project_id):
- query = request.query_params.get("search", False)
- workspace_search = request.query_params.get(
- "workspace_search", "false"
- )
- parent = request.query_params.get("parent", "false")
- issue_relation = request.query_params.get("issue_relation", "false")
- cycle = request.query_params.get("cycle", "false")
- module = request.query_params.get("module", False)
- sub_issue = request.query_params.get("sub_issue", "false")
- target_date = request.query_params.get("target_date", True)
-
- issue_id = request.query_params.get("issue_id", False)
-
- issues = Issue.issue_objects.filter(
- workspace__slug=slug,
- project__project_projectmember__member=self.request.user,
- project__project_projectmember__is_active=True,
- project__archived_at__isnull=True
- )
-
- if workspace_search == "false":
- issues = issues.filter(project_id=project_id)
-
- if query:
- issues = search_issues(query, issues)
-
- if parent == "true" and issue_id:
- issue = Issue.issue_objects.get(pk=issue_id)
- issues = issues.filter(
- ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
- )
- if issue_relation == "true" and issue_id:
- issue = Issue.issue_objects.get(pk=issue_id)
- issues = issues.filter(
- ~Q(pk=issue_id),
- ~Q(issue_related__issue=issue),
- ~Q(issue_relation__related_issue=issue),
- )
- if sub_issue == "true" and issue_id:
- issue = Issue.issue_objects.get(pk=issue_id)
- issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
- if issue.parent:
- issues = issues.filter(~Q(pk=issue.parent_id))
-
- if cycle == "true":
- issues = issues.exclude(issue_cycle__isnull=False)
-
- if module:
- issues = issues.exclude(issue_module__module=module)
-
- if target_date == "none":
- issues = issues.filter(target_date__isnull=True)
-
- return Response(
- issues.values(
- "name",
- "id",
- "start_date",
- "sequence_id",
- "project__name",
- "project__identifier",
- "project_id",
- "workspace__slug",
- "state__name",
- "state__group",
- "state__color",
- ),
- status=status.HTTP_200_OK,
- )
diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py
new file mode 100644
index 000000000..50b468715
--- /dev/null
+++ b/apiserver/plane/app/views/search/issue.py
@@ -0,0 +1,95 @@
+# Python imports
+import re
+
+# Django imports
+from django.db.models import Q
+
+# Third party imports
+from rest_framework import status
+from rest_framework.response import Response
+
+# Module imports
+from .base import BaseAPIView
+from plane.db.models import (
+ Workspace,
+ Project,
+ Issue,
+ Cycle,
+ Module,
+ Page,
+ IssueView,
+)
+from plane.utils.issue_search import search_issues
+
+
+class IssueSearchEndpoint(BaseAPIView):
+ def get(self, request, slug, project_id):
+ query = request.query_params.get("search", False)
+ workspace_search = request.query_params.get(
+ "workspace_search", "false"
+ )
+ parent = request.query_params.get("parent", "false")
+ issue_relation = request.query_params.get("issue_relation", "false")
+ cycle = request.query_params.get("cycle", "false")
+ module = request.query_params.get("module", False)
+ sub_issue = request.query_params.get("sub_issue", "false")
+ target_date = request.query_params.get("target_date", True)
+
+ issue_id = request.query_params.get("issue_id", False)
+
+ issues = Issue.issue_objects.filter(
+ workspace__slug=slug,
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ project__archived_at__isnull=True,
+ )
+
+ if workspace_search == "false":
+ issues = issues.filter(project_id=project_id)
+
+ if query:
+ issues = search_issues(query, issues)
+
+ if parent == "true" and issue_id:
+ issue = Issue.issue_objects.get(pk=issue_id)
+ issues = issues.filter(
+ ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
+ )
+ if issue_relation == "true" and issue_id:
+ issue = Issue.issue_objects.get(pk=issue_id)
+ issues = issues.filter(
+ ~Q(pk=issue_id),
+ ~Q(issue_related__issue=issue),
+ ~Q(issue_relation__related_issue=issue),
+ )
+ if sub_issue == "true" and issue_id:
+ issue = Issue.issue_objects.get(pk=issue_id)
+ issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
+ if issue.parent:
+ issues = issues.filter(~Q(pk=issue.parent_id))
+
+ if cycle == "true":
+ issues = issues.exclude(issue_cycle__isnull=False)
+
+ if module:
+ issues = issues.exclude(issue_module__module=module)
+
+ if target_date == "none":
+ issues = issues.filter(target_date__isnull=True)
+
+ return Response(
+ issues.values(
+ "name",
+ "id",
+ "start_date",
+ "sequence_id",
+ "project__name",
+ "project__identifier",
+ "project_id",
+ "workspace__slug",
+ "state__name",
+ "state__group",
+ "state__color",
+ ),
+ status=status.HTTP_200_OK,
+ )
diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py
index de1559b0c..ac0c3d711 100644
--- a/apiserver/plane/app/views/user/base.py
+++ b/apiserver/plane/app/views/user/base.py
@@ -35,6 +35,8 @@ from plane.license.models import Instance, InstanceAdmin
from plane.utils.cache import cache_response, invalidate_cache
from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
+from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
+from plane.utils.host import base_host
class UserEndpoint(BaseViewSet):
@@ -192,6 +194,11 @@ class UserEndpoint(BaseViewSet):
user.last_logout_time = timezone.now()
user.save()
+ # Send an email to the user
+ user_deactivation_email.delay(
+ base_host(request=request, is_app=True), user.id
+ )
+
# Logout the user
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -250,6 +257,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
+ order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py
index 72c27d20a..079430129 100644
--- a/apiserver/plane/app/views/view/base.py
+++ b/apiserver/plane/app/views/view/base.py
@@ -1,49 +1,61 @@
# Django imports
-from django.db.models import (
- Q,
- OuterRef,
- Func,
- F,
- Case,
- Value,
- CharField,
- When,
- Exists,
- Max,
-)
-from django.utils.decorators import method_decorator
-from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
-from django.db.models import UUIDField
+from django.db.models import (
+ Exists,
+ F,
+ Func,
+ OuterRef,
+ Q,
+ UUIDField,
+ Value,
+)
from django.db.models.functions import Coalesce
+from django.utils.decorators import method_decorator
+from django.views.decorators.gzip import gzip_page
+from rest_framework import status
+from django.db import transaction
# Third party imports
from rest_framework.response import Response
-from rest_framework import status
+
+from plane.app.permissions import (
+ ProjectEntityPermission,
+ WorkspaceEntityPermission,
+)
+from plane.app.serializers import (
+ IssueViewSerializer,
+)
+from plane.db.models import (
+ Issue,
+ IssueAttachment,
+ IssueLink,
+ IssueView,
+ Workspace,
+ WorkspaceMember,
+ ProjectMember,
+)
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
+from plane.utils.issue_filters import issue_filters
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
# Module imports
from .. import BaseViewSet
-from plane.app.serializers import (
- IssueViewSerializer,
- IssueSerializer,
-)
-from plane.app.permissions import (
- WorkspaceEntityPermission,
- ProjectEntityPermission,
-)
-from plane.db.models import (
- Workspace,
- IssueView,
- Issue,
- UserFavorite,
- IssueLink,
- IssueAttachment,
-)
-from plane.utils.issue_filters import issue_filters
-from plane.utils.user_timezone_converter import user_timezone_converter
-class GlobalViewViewSet(BaseViewSet):
+from plane.db.models import (
+ UserFavorite,
+)
+
+
+class WorkspaceViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
@@ -52,7 +64,7 @@ class GlobalViewViewSet(BaseViewSet):
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
- serializer.save(workspace_id=workspace.id)
+ serializer.save(workspace_id=workspace.id, owned_by=self.request.user)
def get_queryset(self):
return self.filter_queryset(
@@ -60,13 +72,70 @@ class GlobalViewViewSet(BaseViewSet):
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
+ .filter(Q(owned_by=self.request.user) | Q(access=1))
.select_related("workspace")
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
+ def partial_update(self, request, slug, pk):
+ with transaction.atomic():
+ workspace_view = IssueView.objects.select_for_update().get(
+ pk=pk,
+ workspace__slug=slug,
+ )
-class GlobalViewIssuesViewSet(BaseViewSet):
+ if workspace_view.is_locked:
+ return Response(
+ {"error": "view is locked"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Only update the view if owner is updating
+ if workspace_view.owned_by_id != request.user.id:
+ return Response(
+ {
+ "error": "Only the owner of the view can update the view"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ serializer = IssueViewSerializer(
+ workspace_view, 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, pk):
+ workspace_view = IssueView.objects.get(
+ pk=pk,
+ workspace__slug=slug,
+ )
+ workspace_member = WorkspaceMember.objects.filter(
+ workspace__slug=slug,
+ member=request.user,
+ role=20,
+ is_active=True,
+ )
+ if (
+ workspace_member.exists()
+ or workspace_view.owned_by == request.user
+ ):
+ workspace_view.delete()
+ else:
+ return Response(
+ {"error": "Only admin or owner can delete the view"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class WorkspaceViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
@@ -143,17 +212,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(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 = (
@@ -162,103 +220,107 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
- # 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")
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- # 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)
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
- if self.fields:
- issues = IssueSerializer(
- issue_queryset, many=True, fields=self.fields
- ).data
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ # Check group and sub group value paginate
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ # group and sub group pagination
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=None,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ project_id=None,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ # Group Paginate
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ project_id=None,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
else:
- issues = issue_queryset.values(
- "id",
- "name",
- "state_id",
- "sort_order",
- "completed_at",
- "estimate_point",
- "priority",
- "start_date",
- "target_date",
- "sequence_id",
- "project_id",
- "parent_id",
- "cycle_id",
- "module_ids",
- "label_ids",
- "assignee_ids",
- "sub_issues_count",
- "created_at",
- "updated_at",
- "created_by",
- "updated_by",
- "attachment_count",
- "link_count",
- "is_draft",
- "archived_at",
+ # List Paginate
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
)
- datetime_fields = ["created_at", "updated_at"]
- issues = user_timezone_converter(
- issues, datetime_fields, request.user.user_timezone
- )
- return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
@@ -269,7 +331,10 @@ class IssueViewViewSet(BaseViewSet):
]
def perform_create(self, serializer):
- serializer.save(project_id=self.kwargs.get("project_id"))
+ serializer.save(
+ project_id=self.kwargs.get("project_id"),
+ owned_by=self.request.user,
+ )
def get_queryset(self):
subquery = UserFavorite.objects.filter(
@@ -289,6 +354,7 @@ class IssueViewViewSet(BaseViewSet):
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
+ .filter(Q(owned_by=self.request.user) | Q(access=1))
.select_related("project")
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
@@ -308,6 +374,60 @@ class IssueViewViewSet(BaseViewSet):
).data
return Response(views, status=status.HTTP_200_OK)
+ def partial_update(self, request, slug, project_id, pk):
+ with transaction.atomic():
+ issue_view = IssueView.objects.select_for_update().get(
+ pk=pk, workspace__slug=slug, project_id=project_id
+ )
+
+ if issue_view.is_locked:
+ return Response(
+ {"error": "view is locked"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Only update the view if owner is updating
+ if issue_view.owned_by_id != request.user.id:
+ return Response(
+ {
+ "error": "Only the owner of the view can update the view"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ serializer = IssueViewSerializer(
+ issue_view, 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_view = IssueView.objects.get(
+ pk=pk,
+ project_id=project_id,
+ workspace__slug=slug,
+ )
+ project_member = ProjectMember.objects.filter(
+ workspace__slug=slug,
+ project_id=project_id,
+ member=request.user,
+ role=20,
+ is_active=True,
+ )
+ if project_member.exists() or project_view.owned_by == request.user:
+ project_view.delete()
+ else:
+ return Response(
+ {"error": "Only admin or owner can delete the view"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
class IssueViewFavoriteViewSet(BaseViewSet):
model = UserFavorite
diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py
index 830ae1dc2..afe998580 100644
--- a/apiserver/plane/app/views/workspace/base.py
+++ b/apiserver/plane/app/views/workspace/base.py
@@ -44,6 +44,7 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.utils.cache import cache_response, invalidate_cache
+from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
class WorkSpaceViewSet(BaseViewSet):
@@ -118,7 +119,7 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
- if serializer.is_valid():
+ if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
_ = WorkspaceMember.objects.create(
@@ -231,7 +232,10 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- workspace = Workspace.objects.filter(slug=slug).exists()
+ workspace = (
+ Workspace.objects.filter(slug=slug).exists()
+ or slug in RESTRICTED_WORKSPACE_SLUGS
+ )
return Response({"status": not workspace}, status=status.HTTP_200_OK)
diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py
index fa2954d67..f642416e3 100644
--- a/apiserver/plane/app/views/workspace/cycle.py
+++ b/apiserver/plane/app/views/workspace/cycle.py
@@ -2,7 +2,6 @@
from django.db.models import (
Q,
Count,
- Sum,
)
# Third party modules
@@ -87,29 +86,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
),
)
)
- .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,
- ),
- )
- )
.order_by(self.kwargs.get("order_by", "-created_at"))
.distinct()
)
diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py
index 94a22a1a7..addb8c5ac 100644
--- a/apiserver/plane/app/views/workspace/user.py
+++ b/apiserver/plane/app/views/workspace/user.py
@@ -1,61 +1,66 @@
# Python imports
from datetime import date
+
from dateutil.relativedelta import relativedelta
# Django imports
-from django.utils import timezone
from django.db.models import (
- OuterRef,
- Func,
- F,
- Q,
- Count,
Case,
- Value,
- CharField,
- When,
- Max,
+ Count,
+ F,
+ Func,
IntegerField,
- UUIDField,
+ OuterRef,
+ Q,
+ Value,
+ When,
)
-from django.db.models.functions import ExtractWeek, Cast
from django.db.models.fields import DateField
-from django.contrib.postgres.aggregates import ArrayAgg
-from django.contrib.postgres.fields import ArrayField
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Cast, ExtractWeek
+from django.utils import timezone
# Third party modules
from rest_framework import status
from rest_framework.response import Response
-# Module imports
-from plane.app.serializers import (
- WorkSpaceSerializer,
- ProjectMemberSerializer,
- IssueActivitySerializer,
- IssueSerializer,
- WorkspaceUserPropertiesSerializer,
-)
-from plane.app.views.base import BaseAPIView
-from plane.db.models import (
- User,
- Workspace,
- ProjectMember,
- IssueActivity,
- Issue,
- IssueLink,
- IssueAttachment,
- IssueSubscriber,
- Project,
- WorkspaceMember,
- CycleIssue,
- WorkspaceUserProperties,
-)
from plane.app.permissions import (
WorkspaceEntityPermission,
WorkspaceViewerPermission,
)
+
+# Module imports
+from plane.app.serializers import (
+ IssueActivitySerializer,
+ ProjectMemberSerializer,
+ WorkSpaceSerializer,
+ WorkspaceUserPropertiesSerializer,
+)
+from plane.app.views.base import BaseAPIView
+from plane.db.models import (
+ CycleIssue,
+ Issue,
+ IssueActivity,
+ IssueAttachment,
+ IssueLink,
+ IssueSubscriber,
+ Project,
+ ProjectMember,
+ User,
+ Workspace,
+ WorkspaceMember,
+ WorkspaceUserProperties,
+)
+from plane.utils.grouper import (
+ issue_group_values,
+ issue_on_results,
+ issue_queryset_grouper,
+)
from plane.utils.issue_filters import issue_filters
+from plane.utils.order_queryset import order_issue_queryset
+from plane.utils.paginator import (
+ GroupedOffsetPaginator,
+ SubGroupedOffsetPaginator,
+)
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@@ -99,22 +104,8 @@ 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
- priority_order = ["urgent", "high", "medium", "low", "none"]
- state_order = [
- "backlog",
- "unstarted",
- "started",
- "completed",
- "cancelled",
- ]
+ filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
@@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "labels__id",
- distinct=True,
- filter=~Q(labels__id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- assignee_ids=Coalesce(
- ArrayAgg(
- "assignees__id",
- distinct=True,
- filter=~Q(assignees__id__isnull=True)
- & Q(assignees__member_project__is_active=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- module_ids=Coalesce(
- ArrayAgg(
- "issue_module__module_id",
- distinct=True,
- filter=~Q(issue_module__module_id__isnull=True),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
.order_by("created_at")
).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")
+ # Issue queryset
+ issue_queryset, order_by_param = order_issue_queryset(
+ issue_queryset=issue_queryset,
+ order_by_param=order_by_param,
+ )
- # 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(),
+ # Group by
+ group_by = request.GET.get("group_by", False)
+ sub_group_by = request.GET.get("sub_group_by", False)
+
+ # issue queryset
+ issue_queryset = issue_queryset_grouper(
+ queryset=issue_queryset,
+ group_by=group_by,
+ sub_group_by=sub_group_by,
+ )
+
+ if group_by:
+ if sub_group_by:
+ if group_by == sub_group_by:
+ return Response(
+ {
+ "error": "Group by and sub group by cannot have same parameters"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=SubGroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ filters=filters,
+ ),
+ sub_group_by_fields=issue_group_values(
+ field=sub_group_by,
+ slug=slug,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ sub_group_by_field_name=sub_group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
+ )
+ else:
+ # Group paginate
+ return self.paginate(
+ request=request,
+ order_by=order_by_param,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by,
+ issues=issues,
+ sub_group_by=sub_group_by,
+ ),
+ paginator_cls=GroupedOffsetPaginator,
+ group_by_fields=issue_group_values(
+ field=group_by,
+ slug=slug,
+ filters=filters,
+ ),
+ group_by_field_name=group_by,
+ count_filter=Q(
+ Q(issue_inbox__status=1)
+ | Q(issue_inbox__status=-1)
+ | Q(issue_inbox__status=2)
+ | Q(issue_inbox__isnull=True),
+ archived_at__isnull=True,
+ is_draft=False,
+ ),
)
- ).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 = IssueSerializer(
- issue_queryset, many=True, fields=fields if fields else None
- ).data
- return Response(issues, status=status.HTTP_200_OK)
+ return self.paginate(
+ order_by=order_by_param,
+ request=request,
+ queryset=issue_queryset,
+ on_results=lambda issues: issue_on_results(
+ group_by=group_by, issues=issues, sub_group_by=sub_group_by
+ ),
+ )
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
@@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
queryset = queryset.filter(project__in=projects)
return self.paginate(
+ order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py
index 7b899e63c..906d55700 100644
--- a/apiserver/plane/authentication/adapter/base.py
+++ b/apiserver/plane/authentication/adapter/base.py
@@ -4,6 +4,8 @@ import uuid
# Django imports
from django.utils import timezone
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
# Third party imports
from zxcvbn import zxcvbn
@@ -16,6 +18,8 @@ from plane.db.models import (
)
from plane.license.utils.instance_value import get_configuration_value
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
+from plane.bgtasks.user_activation_email_task import user_activation_email
+from plane.authentication.utils.host import base_host
class Adapter:
@@ -46,68 +50,71 @@ class Adapter:
def authenticate(self):
raise NotImplementedError
- def complete_login_or_signup(self):
- email = self.user_data.get("email")
- user = User.objects.filter(email=email).first()
- # Check if sign up case or login
- is_signup = bool(user)
- if not user:
- # New user
- (ENABLE_SIGNUP,) = get_configuration_value(
- [
- {
- "key": "ENABLE_SIGNUP",
- "default": os.environ.get("ENABLE_SIGNUP", "1"),
- },
- ]
- )
- if (
- ENABLE_SIGNUP == "0"
- and not WorkspaceMemberInvite.objects.filter(
- email=email,
- ).exists()
- ):
- raise AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
- error_message="SIGNUP_DISABLED",
- payload={"email": email},
- )
- user = User(email=email, username=uuid.uuid4().hex)
-
- if self.user_data.get("user").get("is_password_autoset"):
- user.set_password(uuid.uuid4().hex)
- user.is_password_autoset = True
- user.is_email_verified = True
- else:
- # Validate password
- results = zxcvbn(self.code)
- if results["score"] < 3:
- raise AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "INVALID_PASSWORD"
- ],
- error_message="INVALID_PASSWORD",
- payload={"email": email},
- )
-
- user.set_password(self.code)
- user.is_password_autoset = False
-
- avatar = self.user_data.get("user", {}).get("avatar", "")
- first_name = self.user_data.get("user", {}).get("first_name", "")
- last_name = self.user_data.get("user", {}).get("last_name", "")
- user.avatar = avatar if avatar else ""
- user.first_name = first_name if first_name else ""
- user.last_name = last_name if last_name else ""
- user.save()
- Profile.objects.create(user=user)
-
- if not user.is_active:
+ def sanitize_email(self, email):
+ # Check if email is present
+ if not email:
raise AuthenticationException(
- AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
- error_message="USER_ACCOUNT_DEACTIVATED",
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ payload={"email": email},
)
+ # Sanitize email
+ email = str(email).lower().strip()
+
+ # validate email
+ try:
+ validate_email(email)
+ except ValidationError:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
+ error_message="INVALID_EMAIL",
+ payload={"email": email},
+ )
+ # Return email
+ return email
+
+ def validate_password(self, email):
+ """Validate password strength"""
+ results = zxcvbn(self.code)
+ if results["score"] < 3:
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
+ error_message="INVALID_PASSWORD",
+ payload={"email": email},
+ )
+ return
+
+ def __check_signup(self, email):
+ """Check if sign up is enabled or not and raise exception if not enabled"""
+
+ # Get configuration value
+ (ENABLE_SIGNUP,) = get_configuration_value(
+ [
+ {
+ "key": "ENABLE_SIGNUP",
+ "default": os.environ.get("ENABLE_SIGNUP", "1"),
+ },
+ ]
+ )
+
+ # Check if sign up is disabled and invite is present or not
+ if (
+ ENABLE_SIGNUP == "0"
+ and not WorkspaceMemberInvite.objects.filter(
+ email=email,
+ ).exists()
+ ):
+ # Raise exception
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
+ error_message="SIGNUP_DISABLED",
+ payload={"email": email},
+ )
+
+ return True
+
+ def save_user_data(self, user):
# Update user details
user.last_login_medium = self.provider
user.last_active = timezone.now()
@@ -115,8 +122,65 @@ class Adapter:
user.last_login_ip = self.request.META.get("REMOTE_ADDR")
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
+ # If user is not active, send the activation email and set the user as active
+ if not user.is_active:
+ user_activation_email.delay(
+ base_host(request=self.request), user.id
+ )
+ # Set user as active
+ user.is_active = True
user.save()
+ return user
+ def complete_login_or_signup(self):
+ # Get email
+ email = self.user_data.get("email")
+
+ # Sanitize email
+ email = self.sanitize_email(email)
+
+ # Check if the user is present
+ user = User.objects.filter(email=email).first()
+ # Check if sign up case or login
+ is_signup = bool(user)
+ # If user is not present, create a new user
+ if not user:
+ # New user
+ self.__check_signup(email)
+
+ # Initialize user
+ user = User(email=email, username=uuid.uuid4().hex)
+
+ # Check if password is autoset
+ if self.user_data.get("user").get("is_password_autoset"):
+ user.set_password(uuid.uuid4().hex)
+ user.is_password_autoset = True
+ user.is_email_verified = True
+
+ # Validate password
+ else:
+ # Validate password
+ self.validate_password(email)
+ # Set password
+ user.set_password(self.code)
+ user.is_password_autoset = False
+
+ # Set user details
+ avatar = self.user_data.get("user", {}).get("avatar", "")
+ first_name = self.user_data.get("user", {}).get("first_name", "")
+ last_name = self.user_data.get("user", {}).get("last_name", "")
+ user.avatar = avatar if avatar else ""
+ user.first_name = first_name if first_name else ""
+ user.last_name = last_name if last_name else ""
+ user.save()
+
+ # Create profile
+ Profile.objects.create(user=user)
+
+ # Save user data
+ user = self.save_user_data(user=user)
+
+ # Call callback if present
if self.callback:
self.callback(
user,
@@ -124,7 +188,9 @@ class Adapter:
self.request,
)
+ # Create or update account if token data is present
if self.token_data:
self.create_update_account(user=user)
+ # Return user
return user
diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py
index 7b12db945..90e236a80 100644
--- a/apiserver/plane/authentication/adapter/error.py
+++ b/apiserver/plane/authentication/adapter/error.py
@@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = {
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100,
"EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102,
# Oauth
+ "OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
+ "GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
+ "GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -58,6 +61,8 @@ AUTHENTICATION_ERROR_CODES = {
"ADMIN_USER_DEACTIVATED": 5190,
# Rate limit
"RATE_LIMIT_EXCEEDED": 5900,
+ # Unknown
+ "AUTHENTICATION_FAILED": 5999,
}
diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py
index a917c002a..ffda0212f 100644
--- a/apiserver/plane/authentication/adapter/oauth.py
+++ b/apiserver/plane/authentication/adapter/oauth.py
@@ -39,6 +39,16 @@ class OauthAdapter(Adapter):
self.client_secret = client_secret
self.code = code
+ def authentication_error_code(self):
+ if self.provider == "google":
+ return "GOOGLE_OAUTH_PROVIDER_ERROR"
+ elif self.provider == "github":
+ return "GITHUB_OAUTH_PROVIDER_ERROR"
+ elif self.provider == "gitlab":
+ return "GITLAB_OAUTH_PROVIDER_ERROR"
+ else:
+ return "OAUTH_NOT_CONFIGURED"
+
def get_auth_url(self):
return self.auth_url
@@ -62,11 +72,7 @@ class OauthAdapter(Adapter):
response.raise_for_status()
return response.json()
except requests.RequestException:
- code = (
- "GOOGLE_OAUTH_PROVIDER_ERROR"
- if self.provider == "google"
- else "GITHUB_OAUTH_PROVIDER_ERROR"
- )
+ code = self.authentication_error_code()
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
@@ -81,11 +87,7 @@ class OauthAdapter(Adapter):
response.raise_for_status()
return response.json()
except requests.RequestException:
- code = (
- "GOOGLE_OAUTH_PROVIDER_ERROR"
- if self.provider == "google"
- else "GITHUB_OAUTH_PROVIDER_ERROR"
- )
+ code = self.authentication_error_code()
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES[code],
error_message=str(code),
diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py
index 21309ea9c..418dd2a06 100644
--- a/apiserver/plane/authentication/provider/credentials/magic_code.py
+++ b/apiserver/plane/authentication/provider/credentials/magic_code.py
@@ -48,7 +48,7 @@ class MagicCodeProvider(CredentialAdapter):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"],
error_message="SMTP_NOT_CONFIGURED",
- payload={"email": str(self.key)},
+ payload={"email": str(key)},
)
if ENABLE_MAGIC_LINK_LOGIN == "0":
@@ -57,7 +57,7 @@ class MagicCodeProvider(CredentialAdapter):
"MAGIC_LINK_LOGIN_DISABLED"
],
error_message="MAGIC_LINK_LOGIN_DISABLED",
- payload={"email": str(self.key)},
+ payload={"email": str(key)},
)
super().__init__(
diff --git a/apiserver/plane/authentication/provider/oauth/gitlab.py b/apiserver/plane/authentication/provider/oauth/gitlab.py
new file mode 100644
index 000000000..3795cc37f
--- /dev/null
+++ b/apiserver/plane/authentication/provider/oauth/gitlab.py
@@ -0,0 +1,132 @@
+# Python imports
+import os
+from datetime import datetime
+from urllib.parse import urlencode
+
+import pytz
+
+# Module imports
+from plane.authentication.adapter.oauth import OauthAdapter
+from plane.license.utils.instance_value import get_configuration_value
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GitLabOAuthProvider(OauthAdapter):
+
+ provider = "gitlab"
+ scope = "read_user"
+
+ def __init__(self, request, code=None, state=None, callback=None):
+
+ GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = (
+ get_configuration_value(
+ [
+ {
+ "key": "GITLAB_CLIENT_ID",
+ "default": os.environ.get("GITLAB_CLIENT_ID"),
+ },
+ {
+ "key": "GITLAB_CLIENT_SECRET",
+ "default": os.environ.get("GITLAB_CLIENT_SECRET"),
+ },
+ {
+ "key": "GITLAB_HOST",
+ "default": os.environ.get(
+ "GITLAB_HOST", "https://gitlab.com"
+ ),
+ },
+ ]
+ )
+ )
+
+ self.host = GITLAB_HOST
+ self.token_url = f"{self.host}/oauth/token"
+ self.userinfo_url = f"{self.host}/api/v4/user"
+
+ if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
+ raise AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
+ error_message="GITLAB_NOT_CONFIGURED",
+ )
+
+ client_id = GITLAB_CLIENT_ID
+ client_secret = GITLAB_CLIENT_SECRET
+
+ redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
+ url_params = {
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "response_type": "code",
+ "scope": self.scope,
+ "state": state,
+ }
+ auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}"
+ super().__init__(
+ request,
+ self.provider,
+ client_id,
+ self.scope,
+ redirect_uri,
+ auth_url,
+ self.token_url,
+ self.userinfo_url,
+ client_secret,
+ code,
+ callback=callback,
+ )
+
+ def set_token_data(self):
+ data = {
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ "code": self.code,
+ "redirect_uri": self.redirect_uri,
+ "grant_type": "authorization_code",
+ }
+ token_response = self.get_user_token(
+ data=data, headers={"Accept": "application/json"}
+ )
+ super().set_token_data(
+ {
+ "access_token": token_response.get("access_token"),
+ "refresh_token": token_response.get("refresh_token", None),
+ "access_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("created_at")
+ + token_response.get("expires_in"),
+ tz=pytz.utc,
+ )
+ if token_response.get("expires_in")
+ else None
+ ),
+ "refresh_token_expired_at": (
+ datetime.fromtimestamp(
+ token_response.get("refresh_token_expired_at"),
+ tz=pytz.utc,
+ )
+ if token_response.get("refresh_token_expired_at")
+ else None
+ ),
+ "id_token": token_response.get("id_token", ""),
+ }
+ )
+
+ def set_user_data(self):
+ user_info_response = self.get_user_response()
+ email = user_info_response.get("email")
+ super().set_user_data(
+ {
+ "email": email,
+ "user": {
+ "provider_id": user_info_response.get("id"),
+ "email": email,
+ "avatar": user_info_response.get("avatar_url"),
+ "first_name": user_info_response.get("name"),
+ "last_name": user_info_response.get("family_name"),
+ "is_password_autoset": True,
+ },
+ }
+ )
diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py
index ee860f41f..a375d94cb 100644
--- a/apiserver/plane/authentication/urls.py
+++ b/apiserver/plane/authentication/urls.py
@@ -8,6 +8,8 @@ from .views import (
ChangePasswordEndpoint,
# App
EmailCheckEndpoint,
+ GitLabCallbackEndpoint,
+ GitLabOauthInitiateEndpoint,
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
GoogleCallbackEndpoint,
@@ -22,6 +24,8 @@ from .views import (
ResetPasswordSpaceEndpoint,
# Space
EmailCheckSpaceEndpoint,
+ GitLabCallbackSpaceEndpoint,
+ GitLabOauthInitiateSpaceEndpoint,
GitHubCallbackSpaceEndpoint,
GitHubOauthInitiateSpaceEndpoint,
GoogleCallbackSpaceEndpoint,
@@ -151,6 +155,27 @@ urlpatterns = [
GitHubCallbackSpaceEndpoint.as_view(),
name="github-callback",
),
+ ## Gitlab Oauth
+ path(
+ "gitlab/",
+ GitLabOauthInitiateEndpoint.as_view(),
+ name="gitlab-initiate",
+ ),
+ path(
+ "gitlab/callback/",
+ GitLabCallbackEndpoint.as_view(),
+ name="gitlab-callback",
+ ),
+ path(
+ "spaces/gitlab/",
+ GitLabOauthInitiateSpaceEndpoint.as_view(),
+ name="gitlab-initiate",
+ ),
+ path(
+ "spaces/gitlab/callback/",
+ GitLabCallbackSpaceEndpoint.as_view(),
+ name="gitlab-callback",
+ ),
# Email Check
path(
"email-check/",
diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py
index 8910ec637..3b6f231ed 100644
--- a/apiserver/plane/authentication/utils/workspace_project_join.py
+++ b/apiserver/plane/authentication/utils/workspace_project_join.py
@@ -4,6 +4,7 @@ from plane.db.models import (
WorkspaceMember,
WorkspaceMemberInvite,
)
+from plane.utils.cache import invalidate_cache_directly
def process_workspace_project_invitations(user):
@@ -26,6 +27,16 @@ def process_workspace_project_invitations(user):
ignore_conflicts=True,
)
+ [
+ invalidate_cache_directly(
+ path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
+ url_params=False,
+ user=False,
+ multiple=True,
+ )
+ for workspace_member_invite in workspace_member_invites
+ ]
+
# Check if user has any project invites
project_member_invites = ProjectMemberInvite.objects.filter(
email=user.email, accepted=True
diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py
index 51ea3e60a..af58a9cbd 100644
--- a/apiserver/plane/authentication/views/__init__.py
+++ b/apiserver/plane/authentication/views/__init__.py
@@ -14,6 +14,10 @@ from .app.github import (
GitHubCallbackEndpoint,
GitHubOauthInitiateEndpoint,
)
+from .app.gitlab import (
+ GitLabCallbackEndpoint,
+ GitLabOauthInitiateEndpoint,
+)
from .app.google import (
GoogleCallbackEndpoint,
GoogleOauthInitiateEndpoint,
@@ -34,6 +38,11 @@ from .space.github import (
GitHubOauthInitiateSpaceEndpoint,
)
+from .space.gitlab import (
+ GitLabCallbackSpaceEndpoint,
+ GitLabOauthInitiateSpaceEndpoint,
+)
+
from .space.google import (
GoogleCallbackSpaceEndpoint,
GoogleOauthInitiateSpaceEndpoint,
diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py
index 5b3ac7337..6af8859fc 100644
--- a/apiserver/plane/authentication/views/app/check.py
+++ b/apiserver/plane/authentication/views/app/check.py
@@ -95,17 +95,7 @@ class EmailCheckEndpoint(APIView):
# If existing user
if existing_user:
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- return Response(
- exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
- )
-
+ # Return response
return Response(
{
"existing": True,
diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py
index f21e431a4..08a3e8b01 100644
--- a/apiserver/plane/authentication/views/app/email.py
+++ b/apiserver/plane/authentication/views/app/email.py
@@ -107,22 +107,6 @@ class SignInAuthEndpoint(View):
)
return HttpResponseRedirect(url)
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request, is_app=True),
- "sign-in?" + urlencode(params),
- )
- return HttpResponseRedirect(url)
-
try:
provider = EmailProvider(
request=request,
@@ -222,22 +206,6 @@ class SignUpAuthEndpoint(View):
if existing_user:
# Existing User
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request, is_app=True),
- "?" + urlencode(params),
- )
- return HttpResponseRedirect(url)
-
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apiserver/plane/authentication/views/app/gitlab.py
new file mode 100644
index 000000000..02a44aeb4
--- /dev/null
+++ b/apiserver/plane/authentication/views/app/gitlab.py
@@ -0,0 +1,131 @@
+import uuid
+from urllib.parse import urlencode, urljoin
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.authentication.utils.redirection_path import get_redirection_path
+from plane.authentication.utils.user_auth_workflow import (
+ post_user_auth_workflow,
+)
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AuthenticationException,
+ AUTHENTICATION_ERROR_CODES,
+)
+
+
+class GitLabOauthInitiateEndpoint(View):
+
+ def get(self, request):
+ # Get host and next path
+ request.session["host"] = base_host(request=request, is_app=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+ try:
+ state = uuid.uuid4().hex
+ provider = GitLabOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host(request=request, is_app=True),
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+
+class GitLabCallbackEndpoint(View):
+
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITLAB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITLAB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITLAB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITLAB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = GitLabOAuthProvider(
+ request=request,
+ code=code,
+ callback=post_user_auth_workflow,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_app=True)
+ # Get the redirection path
+ if next_path:
+ path = next_path
+ else:
+ path = get_redirection_path(user=user)
+ # redirect to referer path
+ url = urljoin(base_host, path)
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = urljoin(
+ base_host,
+ "?" + urlencode(params),
+ )
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py
index bb3c72534..8b2d1accd 100644
--- a/apiserver/plane/authentication/views/app/magic.py
+++ b/apiserver/plane/authentication/views/app/magic.py
@@ -112,22 +112,6 @@ class MagicSignInEndpoint(View):
)
return HttpResponseRedirect(url)
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = urljoin(
- base_host(request=request, is_app=True),
- "sign-in?" + urlencode(params),
- )
- return HttpResponseRedirect(url)
-
try:
provider = MagicCodeProvider(
request=request,
diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py
index a86a29c09..560ae0e31 100644
--- a/apiserver/plane/authentication/views/space/check.py
+++ b/apiserver/plane/authentication/views/space/check.py
@@ -93,17 +93,7 @@ class EmailCheckSpaceEndpoint(APIView):
# If existing user
if existing_user:
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- return Response(
- exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
- )
-
+ # Return response
return Response(
{
"existing": True,
diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py
index 7a5613a75..4329ed26d 100644
--- a/apiserver/plane/authentication/views/space/email.py
+++ b/apiserver/plane/authentication/views/space/email.py
@@ -89,19 +89,6 @@ class SignInAuthSpaceEndpoint(View):
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
- return HttpResponseRedirect(url)
-
try:
provider = EmailProvider(
request=request, key=email, code=password, is_signup=False
@@ -178,19 +165,6 @@ class SignUpAuthSpaceEndpoint(View):
existing_user = User.objects.filter(email=email).first()
if existing_user:
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
- return HttpResponseRedirect(url)
-
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
error_message="USER_ALREADY_EXIST",
diff --git a/apiserver/plane/authentication/views/space/gitlab.py b/apiserver/plane/authentication/views/space/gitlab.py
new file mode 100644
index 000000000..7ebd9d187
--- /dev/null
+++ b/apiserver/plane/authentication/views/space/gitlab.py
@@ -0,0 +1,109 @@
+# Python imports
+import uuid
+from urllib.parse import urlencode
+
+# Django import
+from django.http import HttpResponseRedirect
+from django.views import View
+
+# Module imports
+from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
+from plane.authentication.utils.login import user_login
+from plane.license.models import Instance
+from plane.authentication.utils.host import base_host
+from plane.authentication.adapter.error import (
+ AUTHENTICATION_ERROR_CODES,
+ AuthenticationException,
+)
+
+
+class GitLabOauthInitiateSpaceEndpoint(View):
+
+ def get(self, request):
+ # Get host and next path
+ request.session["host"] = base_host(request=request, is_space=True)
+ next_path = request.GET.get("next_path")
+ if next_path:
+ request.session["next_path"] = str(next_path)
+
+ # Check instance configuration
+ instance = Instance.objects.first()
+ if instance is None or not instance.is_setup_done:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "INSTANCE_NOT_CONFIGURED"
+ ],
+ error_message="INSTANCE_NOT_CONFIGURED",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ state = uuid.uuid4().hex
+ provider = GitLabOAuthProvider(request=request, state=state)
+ request.session["state"] = state
+ auth_url = provider.get_auth_url()
+ return HttpResponseRedirect(auth_url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+
+class GitLabCallbackSpaceEndpoint(View):
+
+ def get(self, request):
+ code = request.GET.get("code")
+ state = request.GET.get("state")
+ base_host = request.session.get("host")
+ next_path = request.session.get("next_path")
+
+ if state != request.session.get("state", ""):
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITLAB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITLAB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ if not code:
+ exc = AuthenticationException(
+ error_code=AUTHENTICATION_ERROR_CODES[
+ "GITLAB_OAUTH_PROVIDER_ERROR"
+ ],
+ error_message="GITLAB_OAUTH_PROVIDER_ERROR",
+ )
+ params = exc.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
+
+ try:
+ provider = GitLabOAuthProvider(
+ request=request,
+ code=code,
+ )
+ user = provider.authenticate()
+ # Login the user and record his device info
+ user_login(request=request, user=user, is_space=True)
+ # Process workspace and project invitations
+ # redirect to referer path
+ url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
+ return HttpResponseRedirect(url)
+ except AuthenticationException as e:
+ params = e.get_error_dict()
+ if next_path:
+ params["next_path"] = str(next_path)
+ url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
+ return HttpResponseRedirect(url)
diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py
index 0e859d44d..838039f96 100644
--- a/apiserver/plane/authentication/views/space/magic.py
+++ b/apiserver/plane/authentication/views/space/magic.py
@@ -20,7 +20,7 @@ from plane.authentication.utils.login import user_login
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
-from plane.db.models import User, Profile
+from plane.db.models import User
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
@@ -101,18 +101,6 @@ class MagicSignInSpaceEndpoint(View):
return HttpResponseRedirect(url)
# Active User
- if not existing_user.is_active:
- exc = AuthenticationException(
- error_code=AUTHENTICATION_ERROR_CODES[
- "USER_ACCOUNT_DEACTIVATED"
- ],
- error_message="USER_ACCOUNT_DEACTIVATED",
- )
- params = exc.get_error_dict()
- if next_path:
- params["next_path"] = str(next_path)
- url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
- return HttpResponseRedirect(url)
try:
provider = MagicCodeProvider(
request=request, key=f"magic_{email}", code=code
@@ -121,12 +109,7 @@ class MagicSignInSpaceEndpoint(View):
# Login the user and record his device info
user_login(request=request, user=user, is_space=True)
# redirect to referer path
- profile = Profile.objects.get(user=user)
- if user.is_password_autoset and profile.is_onboarded:
- path = "accounts/set-password"
- else:
- # Get the redirection path
- path = str(next_path) if next_path else ""
+ path = str(next_path) if next_path else ""
url = f"{base_host(request=request, is_space=True)}{path}"
return HttpResponseRedirect(url)
diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py
index e76cdac22..74e210de6 100644
--- a/apiserver/plane/bgtasks/dummy_data_task.py
+++ b/apiserver/plane/bgtasks/dummy_data_task.py
@@ -28,6 +28,7 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
Page,
+ ProjectPage,
PageLabel,
Inbox,
InboxIssue,
@@ -37,7 +38,7 @@ from plane.db.models import (
def create_project(workspace, user_id):
fake = Faker()
name = fake.name()
- unique_id = str(uuid.uuid4())[:5]
+ unique_id = str(uuid.uuid4())[:5]
project = Project.objects.create(
workspace=workspace,
@@ -244,7 +245,6 @@ def create_pages(workspace, project, user_id, pages_count):
pages.append(
Page(
name=fake.name(),
- project=project,
workspace=workspace,
owned_by_id=user_id,
access=random.randint(0, 1),
@@ -254,8 +254,20 @@ def create_pages(workspace, project, user_id, pages_count):
is_locked=False,
)
)
-
- return Page.objects.bulk_create(pages, ignore_conflicts=True)
+ # Bulk create pages
+ pages = Page.objects.bulk_create(pages, ignore_conflicts=True)
+ # Add Page to project
+ ProjectPage.objects.bulk_create(
+ [
+ ProjectPage(
+ page=page,
+ project=project,
+ workspace=workspace,
+ )
+ for page in pages
+ ],
+ batch_size=1000,
+ )
def create_page_labels(workspace, project, user_id, pages_count):
@@ -263,7 +275,9 @@ def create_page_labels(workspace, project, user_id, pages_count):
labels = Label.objects.filter(project=project).values_list("id", flat=True)
pages = random.sample(
list(
- Page.objects.filter(project=project).values_list("id", flat=True)
+ Page.objects.filter(projects__id=project.id).values_list(
+ "id", flat=True
+ )
),
int(pages_count / 2),
)
@@ -278,7 +292,6 @@ def create_page_labels(workspace, project, user_id, pages_count):
PageLabel(
page_id=page,
label_id=label,
- project=project,
workspace=workspace,
)
)
@@ -293,8 +306,14 @@ def create_issues(workspace, project, user_id, issue_count):
fake = Faker()
Faker.seed(0)
- states = State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
- creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
+ states = (
+ State.objects.filter(workspace=workspace, project=project)
+ .exclude(group="Triage")
+ .values_list("id", flat=True)
+ )
+ creators = ProjectMember.objects.filter(
+ workspace=workspace, project=project
+ ).values_list("member_id", flat=True)
issues = []
diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py
index c99836c83..cfb6853a7 100644
--- a/apiserver/plane/bgtasks/export_task.py
+++ b/apiserver/plane/bgtasks/export_task.py
@@ -69,26 +69,34 @@ def create_zip_file(files):
def upload_to_s3(zip_file, workspace_id, token_id, slug):
- file_name = (
- f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
- )
+ file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
expires_in = 7 * 24 * 60 * 60
if settings.USE_MINIO:
- s3 = boto3.client(
+ upload_s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
- s3.upload_fileobj(
+ upload_s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
- presigned_url = s3.generate_presigned_url(
+
+ # Generate presigned url for the uploaded file with different base
+ presign_s3 = boto3.client(
+ "s3",
+ endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/",
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ config=Config(signature_version="s3v4"),
+ )
+
+ presigned_url = presign_s3.generate_presigned_url(
"get_object",
Params={
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
@@ -96,19 +104,27 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
},
ExpiresIn=expires_in,
)
- # Create the new url with updated domain and protocol
- presigned_url = presigned_url.replace(
- f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/",
- f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
- )
else:
- s3 = boto3.client(
- "s3",
- region_name=settings.AWS_REGION,
- aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
- config=Config(signature_version="s3v4"),
- )
+
+ # If endpoint url is present, use it
+ if settings.AWS_S3_ENDPOINT_URL:
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=settings.AWS_S3_ENDPOINT_URL,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ config=Config(signature_version="s3v4"),
+ )
+ else:
+ s3 = boto3.client(
+ "s3",
+ region_name=settings.AWS_REGION,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ config=Config(signature_version="s3v4"),
+ )
+
+ # Upload the file to S3
s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
@@ -116,6 +132,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
+ # Generate presigned url for the uploaded file
presigned_url = s3.generate_presigned_url(
"get_object",
Params={
@@ -127,6 +144,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
exporter_instance = ExporterHistory.objects.get(token=token_id)
+ # Update the exporter instance with the presigned url
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 007b3e48c..67cda14af 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -28,6 +28,7 @@ from plane.db.models import (
Project,
State,
User,
+ EstimatePoint,
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
@@ -448,21 +449,37 @@ def track_estimate_points(
if current_instance.get("estimate_point") != requested_data.get(
"estimate_point"
):
+ old_estimate = (
+ EstimatePoint.objects.filter(
+ pk=current_instance.get("estimate_point")
+ ).first()
+ if current_instance.get("estimate_point") is not None
+ else None
+ )
+ new_estimate = (
+ EstimatePoint.objects.filter(
+ pk=requested_data.get("estimate_point")
+ ).first()
+ if requested_data.get("estimate_point") is not None
+ else None
+ )
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
- old_value=(
+ old_identifier=(
current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None
- else ""
+ else None
),
- new_value=(
+ new_identifier=(
requested_data.get("estimate_point")
if requested_data.get("estimate_point") is not None
- else ""
+ else None
),
+ old_value=old_estimate.value if old_estimate else None,
+ new_value=new_estimate.value if new_estimate else None,
field="estimate_point",
project_id=project_id,
workspace_id=workspace_id,
diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py
index eceb3693e..e3cf81a6e 100644
--- a/apiserver/plane/bgtasks/page_transaction_task.py
+++ b/apiserver/plane/bgtasks/page_transaction_task.py
@@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id):
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
- project_id=page.project_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
diff --git a/apiserver/plane/bgtasks/project_add_user_email_task.py b/apiserver/plane/bgtasks/project_add_user_email_task.py
new file mode 100644
index 000000000..c8308465a
--- /dev/null
+++ b/apiserver/plane/bgtasks/project_add_user_email_task.py
@@ -0,0 +1,87 @@
+# Python imports
+import logging
+
+# Third party imports
+from celery import shared_task
+
+# Third party imports
+from django.core.mail import EmailMultiAlternatives, get_connection
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+
+# Module imports
+from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.exception_logger import log_exception
+from plane.db.models import ProjectMember
+from plane.db.models import User
+
+
+@shared_task
+def project_add_user_email(current_site, project_member_id, invitor_id):
+ try:
+ # Get the invitor
+ invitor = User.objects.get(pk=invitor_id)
+ inviter_first_name = invitor.first_name
+ # Get the project member
+ project_member = ProjectMember.objects.get(pk=project_member_id)
+ # Get the project member details
+ project_name = project_member.project.name
+ workspace_name = project_member.workspace.name
+ member_email = project_member.member.email
+ project_url = f"{current_site}/{project_member.workspace.slug}/projects/{project_member.project_id}/issues"
+ # set the context
+ context = {
+ "project_name": project_name,
+ "workspace_name": workspace_name,
+ "email": member_email,
+ "inviter_first_name": inviter_first_name,
+ "project_url": project_url,
+ }
+
+ # Get the email configuration
+ (
+ EMAIL_HOST,
+ EMAIL_HOST_USER,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_PORT,
+ EMAIL_USE_TLS,
+ EMAIL_USE_SSL,
+ EMAIL_FROM,
+ ) = get_email_configuration()
+
+ # Set the subject
+ subject = "You have been invited to a Plane project"
+
+ # Render the email template
+ html_content = render_to_string(
+ "emails/notifications/project_addition.html", context
+ )
+ text_content = strip_tags(html_content)
+ # Initialize the connection
+ connection = get_connection(
+ host=EMAIL_HOST,
+ port=int(EMAIL_PORT),
+ username=EMAIL_HOST_USER,
+ password=EMAIL_HOST_PASSWORD,
+ use_tls=EMAIL_USE_TLS == "1",
+ use_ssl=EMAIL_USE_SSL == "1",
+ )
+ # Send the email
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=text_content,
+ from_email=EMAIL_FROM,
+ to=[member_email],
+ connection=connection,
+ )
+ # Attach the html content
+ msg.attach_alternative(html_content, "text/html")
+ # Send the email
+ msg.send()
+ # Log the success
+ logging.getLogger("plane").info("Email sent successfully.")
+ return
+ except Exception as e:
+ log_exception(e)
+ return
diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py
index b60c49da1..84ef237ef 100644
--- a/apiserver/plane/bgtasks/project_invitation_task.py
+++ b/apiserver/plane/bgtasks/project_invitation_task.py
@@ -5,6 +5,7 @@ import logging
from celery import shared_task
# Django imports
+# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apiserver/plane/bgtasks/user_activation_email_task.py
new file mode 100644
index 000000000..2fdfc4ddb
--- /dev/null
+++ b/apiserver/plane/bgtasks/user_activation_email_task.py
@@ -0,0 +1,70 @@
+# Python imports
+import logging
+
+# Django imports
+from django.core.mail import EmailMultiAlternatives, get_connection
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+# Third party imports
+from celery import shared_task
+
+# Module imports
+from plane.db.models import User
+from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.exception_logger import log_exception
+
+
+@shared_task
+def user_activation_email(current_site, user_id):
+ try:
+ # Send email to user when account is activated
+ user = User.objects.get(id=user_id)
+ subject = f"{user.first_name or user.display_name or user.email} has been activated on Plane"
+
+ context = {
+ "email": str(user.email),
+ "profile_url": current_site + "/profile",
+ }
+
+ # Send email to user
+ html_content = render_to_string(
+ "emails/user/user_activation.html", context
+ )
+
+ text_content = strip_tags(html_content)
+ # Configure email connection from the database
+ (
+ EMAIL_HOST,
+ EMAIL_HOST_USER,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_PORT,
+ EMAIL_USE_TLS,
+ EMAIL_USE_SSL,
+ 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=EMAIL_USE_TLS == "1",
+ use_ssl=EMAIL_USE_SSL == "1",
+ )
+
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=text_content,
+ from_email=EMAIL_FROM,
+ to=[user.email],
+ connection=connection,
+ )
+
+ msg.attach_alternative(html_content, "text/html")
+ msg.send()
+ logging.getLogger("plane").info("Email sent successfully.")
+ return
+ except Exception as e:
+ log_exception(e)
+ return
diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apiserver/plane/bgtasks/user_deactivation_email_task.py
new file mode 100644
index 000000000..fa8523d50
--- /dev/null
+++ b/apiserver/plane/bgtasks/user_deactivation_email_task.py
@@ -0,0 +1,72 @@
+# Python imports
+import logging
+
+# Django imports
+from django.core.mail import EmailMultiAlternatives, get_connection
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+# Third party imports
+from celery import shared_task
+
+# Module imports
+from plane.db.models import User
+from plane.license.utils.instance_value import get_email_configuration
+from plane.utils.exception_logger import log_exception
+
+
+@shared_task
+def user_deactivation_email(current_site, user_id):
+ try:
+ # Send email to user when account is deactivated
+ user = User.objects.get(id=user_id)
+ subject = f"{user.first_name or user.display_name or user.email} has been deactivated on Plane"
+
+ context = {
+ "email": str(user.email),
+ "login_url": current_site + "/login",
+ }
+
+ # Send email to user
+ html_content = render_to_string(
+ "emails/user/user_deactivation.html", context
+ )
+
+ text_content = strip_tags(html_content)
+ # Configure email connection from the database
+ (
+ EMAIL_HOST,
+ EMAIL_HOST_USER,
+ EMAIL_HOST_PASSWORD,
+ EMAIL_PORT,
+ EMAIL_USE_TLS,
+ EMAIL_USE_SSL,
+ 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=EMAIL_USE_TLS == "1",
+ use_ssl=EMAIL_USE_SSL == "1",
+ )
+
+ # Send email
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=text_content,
+ from_email=EMAIL_FROM,
+ to=[user.email],
+ connection=connection,
+ )
+
+ # Attach HTML content
+ msg.attach_alternative(html_content, "text/html")
+ msg.send()
+ logging.getLogger("plane").info("Email sent successfully.")
+ return
+ except Exception as e:
+ log_exception(e)
+ return
diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apiserver/plane/db/migrations/0067_issue_estimate.py
new file mode 100644
index 000000000..b341f9864
--- /dev/null
+++ b/apiserver/plane/db/migrations/0067_issue_estimate.py
@@ -0,0 +1,260 @@
+# # Generated by Django 4.2.7 on 2024-05-24 09:47
+# Python imports
+import uuid
+from uuid import uuid4
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+import plane.db.models.deploy_board
+
+
+def issue_estimate_point(apps, schema_editor):
+ Issue = apps.get_model("db", "Issue")
+ Project = apps.get_model("db", "Project")
+ EstimatePoint = apps.get_model("db", "EstimatePoint")
+ IssueActivity = apps.get_model("db", "IssueActivity")
+ updated_estimate_point = []
+ updated_issue_activity = []
+
+ # loop through all the projects
+ for project in Project.objects.filter(estimate__isnull=False):
+ estimate_points = EstimatePoint.objects.filter(
+ estimate=project.estimate, project=project
+ )
+
+ for issue_activity in IssueActivity.objects.filter(
+ field="estimate_point", project=project
+ ):
+ if issue_activity.new_value:
+ new_identifier = estimate_points.filter(
+ key=issue_activity.new_value
+ ).first().id
+ issue_activity.new_identifier = new_identifier
+ new_value = estimate_points.filter(
+ key=issue_activity.new_value
+ ).first().value
+ issue_activity.new_value = new_value
+
+ if issue_activity.old_value:
+ old_identifier = estimate_points.filter(
+ key=issue_activity.old_value
+ ).first().id
+ issue_activity.old_identifier = old_identifier
+ old_value = estimate_points.filter(
+ key=issue_activity.old_value
+ ).first().value
+ issue_activity.old_value = old_value
+ updated_issue_activity.append(issue_activity)
+
+ for issue in Issue.objects.filter(
+ point__isnull=False, project=project
+ ):
+ # get the estimate id for the corresponding estimate point in the issue
+ estimate = estimate_points.filter(key=issue.point).first()
+ issue.estimate_point = estimate
+ updated_estimate_point.append(issue)
+
+ Issue.objects.bulk_update(
+ updated_estimate_point, ["estimate_point"], batch_size=1000
+ )
+ IssueActivity.objects.bulk_update(
+ updated_issue_activity,
+ ["new_value", "old_value", "new_identifier", "old_identifier"],
+ batch_size=1000,
+ )
+
+
+def last_used_estimate(apps, schema_editor):
+ Project = apps.get_model("db", "Project")
+ Estimate = apps.get_model("db", "Estimate")
+
+ # Get all estimate ids used in projects
+ estimate_ids = Project.objects.filter(estimate__isnull=False).values_list(
+ "estimate", flat=True
+ )
+
+ # Update all matching estimates
+ Estimate.objects.filter(id__in=estimate_ids).update(last_used=True)
+
+
+def populate_deploy_board(apps, schema_editor):
+ DeployBoard = apps.get_model("db", "DeployBoard")
+ ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard")
+
+ DeployBoard.objects.bulk_create(
+ [
+ DeployBoard(
+ entity_identifier=deploy_board.project_id,
+ project_id=deploy_board.project_id,
+ entity_name="project",
+ anchor=uuid4().hex,
+ is_comments_enabled=deploy_board.comments,
+ is_reactions_enabled=deploy_board.reactions,
+ inbox=deploy_board.inbox,
+ is_votes_enabled=deploy_board.votes,
+ view_props=deploy_board.views,
+ workspace_id=deploy_board.workspace_id,
+ created_at=deploy_board.created_at,
+ updated_at=deploy_board.updated_at,
+ created_by_id=deploy_board.created_by_id,
+ updated_by_id=deploy_board.updated_by_id,
+ )
+ for deploy_board in ProjectDeployBoard.objects.all()
+ ],
+ batch_size=100,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0066_account_id_token_cycle_logo_props_module_logo_props"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DeployBoard",
+ 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,
+ ),
+ ),
+ ("entity_identifier", models.UUIDField(null=True)),
+ (
+ "entity_name",
+ models.CharField(
+ choices=[
+ ("project", "Project"),
+ ("issue", "Issue"),
+ ("module", "Module"),
+ ("cycle", "Task"),
+ ("page", "Page"),
+ ("view", "View"),
+ ],
+ max_length=30,
+ ),
+ ),
+ (
+ "anchor",
+ models.CharField(
+ db_index=True,
+ default=plane.db.models.deploy_board.get_anchor,
+ max_length=255,
+ unique=True,
+ ),
+ ),
+ ("is_comments_enabled", models.BooleanField(default=False)),
+ ("is_reactions_enabled", models.BooleanField(default=False)),
+ ("is_votes_enabled", models.BooleanField(default=False)),
+ ("view_props", models.JSONField(default=dict)),
+ (
+ "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",
+ ),
+ ),
+ (
+ "inbox",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="board_inbox",
+ to="db.inbox",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ null=True,
+ 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": "Deploy Board",
+ "verbose_name_plural": "Deploy Boards",
+ "db_table": "deploy_boards",
+ "ordering": ("-created_at",),
+ "unique_together": {("entity_name", "entity_identifier")},
+ },
+ ),
+ migrations.AddField(
+ model_name="estimate",
+ name="last_used",
+ field=models.BooleanField(default=False),
+ ),
+ # Rename the existing field
+ migrations.RenameField(
+ model_name="issue",
+ old_name="estimate_point",
+ new_name="point",
+ ),
+ # Add a new field with the original name as a foreign key
+ migrations.AddField(
+ model_name="issue",
+ name="estimate_point",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="issue_estimates",
+ to="db.EstimatePoint",
+ blank=True,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="estimate",
+ name="type",
+ field=models.CharField(default="categories", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="estimatepoint",
+ name="value",
+ field=models.CharField(max_length=255),
+ ),
+ migrations.RunPython(issue_estimate_point),
+ migrations.RunPython(last_used_estimate),
+ migrations.RunPython(populate_deploy_board),
+ ]
diff --git a/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py
new file mode 100644
index 000000000..50475c2a8
--- /dev/null
+++ b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py
@@ -0,0 +1,257 @@
+# Generated by Django 4.2.11 on 2024-06-07 12:04
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+def migrate_pages(apps, schema_editor):
+ ProjectPage = apps.get_model("db", "ProjectPage")
+ Page = apps.get_model("db", "Page")
+ ProjectPage.objects.bulk_create(
+ [
+ ProjectPage(
+ workspace_id=page.get("workspace_id"),
+ project_id=page.get("project_id"),
+ page_id=page.get("id"),
+ created_by_id=page.get("created_by_id"),
+ updated_by_id=page.get("updated_by_id"),
+ )
+ for page in Page.objects.values(
+ "workspace_id",
+ "project_id",
+ "id",
+ "created_by_id",
+ "updated_by_id",
+ )
+ ],
+ batch_size=1000,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("db", "0067_issue_estimate"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="page",
+ name="is_global",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.CreateModel(
+ name="ProjectPage",
+ 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",
+ ),
+ ),
+ (
+ "page",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_pages",
+ to="db.page",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_pages",
+ 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="project_pages",
+ to="db.workspace",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Project Page",
+ "verbose_name_plural": "Project Pages",
+ "db_table": "project_pages",
+ "ordering": ("-created_at",),
+ "unique_together": {("project", "page")},
+ },
+ ),
+ migrations.CreateModel(
+ name="TeamPage",
+ 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",
+ ),
+ ),
+ (
+ "page",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="team_pages",
+ to="db.page",
+ ),
+ ),
+ (
+ "team",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="team_pages",
+ to="db.team",
+ ),
+ ),
+ (
+ "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="team_pages",
+ to="db.workspace",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Team Page",
+ "verbose_name_plural": "Team Pages",
+ "db_table": "team_pages",
+ "ordering": ("-created_at",),
+ "unique_together": {("team", "page")},
+ },
+ ),
+ migrations.AddField(
+ model_name="page",
+ name="projects",
+ field=models.ManyToManyField(
+ related_name="pages", through="db.ProjectPage", to="db.project"
+ ),
+ ),
+ migrations.AddField(
+ model_name="page",
+ name="teams",
+ field=models.ManyToManyField(
+ related_name="pages", through="db.TeamPage", to="db.team"
+ ),
+ ),
+ migrations.RunPython(migrate_pages),
+ migrations.RemoveField(
+ model_name="page",
+ name="project",
+ ),
+ migrations.AlterField(
+ model_name="page",
+ name="workspace",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pages",
+ to="db.workspace",
+ ),
+ ),
+ migrations.RemoveField(
+ model_name="pagelabel",
+ name="project",
+ ),
+ migrations.RemoveField(
+ model_name="pagelog",
+ name="project",
+ ),
+ migrations.AlterField(
+ model_name="pagelabel",
+ name="workspace",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workspace_page_label",
+ to="db.workspace",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pagelog",
+ name="workspace",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="workspace_page_log",
+ to="db.workspace",
+ ),
+ ),
+ ]
diff --git a/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py b/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py
new file mode 100644
index 000000000..8c806abab
--- /dev/null
+++ b/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py
@@ -0,0 +1,73 @@
+# Generated by Django 4.2.11 on 2024-06-03 17:16
+
+from django.db import migrations, models
+from django.conf import settings
+from django.db.models import F
+import django.db.models.deletion
+
+
+def populate_views_owned_by(apps, schema_editor):
+ IssueView = apps.get_model("db", "IssueView")
+
+ # update all existing views to be owned by the user who created them
+ IssueView.objects.update(owned_by_id=F("created_by_id"))
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="account",
+ name="provider",
+ field=models.CharField(
+ choices=[
+ ("google", "Google"),
+ ("github", "Github"),
+ ("gitlab", "GitLab"),
+ ]
+ ),
+ ),
+ migrations.AlterField(
+ model_name="socialloginconnection",
+ name="medium",
+ field=models.CharField(
+ choices=[
+ ("Google", "google"),
+ ("Github", "github"),
+ ("GitLab", "gitlab"),
+ ("Jira", "jira"),
+ ],
+ default=None,
+ max_length=20,
+ ),
+ ),
+ migrations.AddField(
+ model_name="issueview",
+ name="is_locked",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="issueview",
+ name="owned_by",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="views",
+ to=settings.AUTH_USER_MODEL,
+ null=True,
+ ),
+ ),
+ migrations.RunPython(populate_views_owned_by),
+ migrations.AlterField(
+ model_name="issueview",
+ name="owned_by",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="views",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py
index b11ce7aa3..38586791d 100644
--- a/apiserver/plane/db/models/__init__.py
+++ b/apiserver/plane/db/models/__init__.py
@@ -4,6 +4,7 @@ from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
+from .deploy_board import DeployBoard
from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory
from .importer import Importer
@@ -49,22 +50,22 @@ from .notification import (
Notification,
UserNotificationPreference,
)
-from .page import Page, PageFavorite, PageLabel, PageLog
+from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import (
Project,
ProjectBaseModel,
- ProjectDeployBoard,
ProjectFavorite,
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectPublicMember,
)
+from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
from .user import Account, Profile, User
-from .view import GlobalView, IssueView, IssueViewFavorite
+from .view import IssueView, IssueViewFavorite
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py
index 7dd2f2c91..86e5ceef8 100644
--- a/apiserver/plane/db/models/asset.py
+++ b/apiserver/plane/db/models/asset.py
@@ -12,6 +12,7 @@ from .base import BaseModel
def get_upload_path(instance, filename):
+ filename = filename[:50]
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py
new file mode 100644
index 000000000..41ffbc7c1
--- /dev/null
+++ b/apiserver/plane/db/models/deploy_board.py
@@ -0,0 +1,53 @@
+# Python imports
+from uuid import uuid4
+
+# Django imports
+from django.db import models
+
+# Module imports
+from .workspace import WorkspaceBaseModel
+
+
+def get_anchor():
+ return uuid4().hex
+
+
+class DeployBoard(WorkspaceBaseModel):
+ TYPE_CHOICES = (
+ ("project", "Project"),
+ ("issue", "Issue"),
+ ("module", "Module"),
+ ("cycle", "Task"),
+ ("page", "Page"),
+ ("view", "View"),
+ )
+
+ entity_identifier = models.UUIDField(null=True)
+ entity_name = models.CharField(
+ max_length=30,
+ choices=TYPE_CHOICES,
+ )
+ anchor = models.CharField(
+ max_length=255, default=get_anchor, unique=True, db_index=True
+ )
+ is_comments_enabled = models.BooleanField(default=False)
+ is_reactions_enabled = models.BooleanField(default=False)
+ inbox = models.ForeignKey(
+ "db.Inbox",
+ related_name="board_inbox",
+ on_delete=models.SET_NULL,
+ null=True,
+ )
+ is_votes_enabled = models.BooleanField(default=False)
+ view_props = models.JSONField(default=dict)
+
+ def __str__(self):
+ """Return name of the deploy board"""
+ return f"{self.entity_identifier} <{self.entity_name}>"
+
+ class Meta:
+ unique_together = ["entity_name", "entity_identifier"]
+ verbose_name = "Deploy Board"
+ verbose_name_plural = "Deploy Boards"
+ db_table = "deploy_boards"
+ ordering = ("-created_at",)
diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py
index 6ff1186c3..0713d774f 100644
--- a/apiserver/plane/db/models/estimate.py
+++ b/apiserver/plane/db/models/estimate.py
@@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel):
description = models.TextField(
verbose_name="Estimate Description", blank=True
)
- type = models.CharField(max_length=255, default="Categories")
+ type = models.CharField(max_length=255, default="categories")
+ last_used = models.BooleanField(default=False)
def __str__(self):
"""Return name of the estimate"""
@@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel):
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
)
description = models.TextField(blank=True)
- value = models.CharField(max_length=20)
+ value = models.CharField(max_length=255)
def __str__(self):
"""Return name of the estimate"""
diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py
index 527597ddc..e82b974ed 100644
--- a/apiserver/plane/db/models/issue.py
+++ b/apiserver/plane/db/models/issue.py
@@ -91,6 +91,7 @@ class IssueManager(models.Manager):
| models.Q(issue_inbox__status=2)
| models.Q(issue_inbox__isnull=True)
)
+ .filter(state__is_triage=False)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
@@ -119,11 +120,18 @@ class Issue(ProjectBaseModel):
blank=True,
related_name="state_issue",
)
- estimate_point = models.IntegerField(
+ point = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(12)],
null=True,
blank=True,
)
+ estimate_point = models.ForeignKey(
+ "db.EstimatePoint",
+ on_delete=models.SET_NULL,
+ related_name="issue_estimates",
+ null=True,
+ blank=True,
+ )
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="")
diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py
index e079dcbe5..9a8b3078d 100644
--- a/apiserver/plane/db/models/page.py
+++ b/apiserver/plane/db/models/page.py
@@ -9,13 +9,17 @@ from django.db import models
from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel
+from .base import BaseModel
def get_view_props():
return {"full_width": False}
-class Page(ProjectBaseModel):
+class Page(BaseModel):
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="pages"
+ )
name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
@@ -44,6 +48,13 @@ class Page(ProjectBaseModel):
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict)
+ is_global = models.BooleanField(default=False)
+ projects = models.ManyToManyField(
+ "db.Project", related_name="pages", through="db.ProjectPage"
+ )
+ teams = models.ManyToManyField(
+ "db.Team", related_name="pages", through="db.TeamPage"
+ )
class Meta:
verbose_name = "Page"
@@ -56,7 +67,7 @@ class Page(ProjectBaseModel):
return f"{self.owned_by.email} <{self.name}>"
-class PageLog(ProjectBaseModel):
+class PageLog(BaseModel):
TYPE_CHOICES = (
("to_do", "To Do"),
("issue", "issue"),
@@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel):
choices=TYPE_CHOICES,
verbose_name="Transaction Type",
)
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
+ )
class Meta:
unique_together = ["page", "transaction"]
@@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel):
return f"{self.user.email} <{self.page.name}>"
-class PageLabel(ProjectBaseModel):
+class PageLabel(BaseModel):
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_labels"
)
+ workspace = models.ForeignKey(
+ "db.Workspace",
+ on_delete=models.CASCADE,
+ related_name="workspace_page_label",
+ )
class Meta:
verbose_name = "Page Label"
@@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel):
def __str__(self):
return f"{self.page.name} {self.label.name}"
+
+
+class ProjectPage(BaseModel):
+ project = models.ForeignKey(
+ "db.Project", on_delete=models.CASCADE, related_name="project_pages"
+ )
+ page = models.ForeignKey(
+ "db.Page", on_delete=models.CASCADE, related_name="project_pages"
+ )
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="project_pages"
+ )
+
+ class Meta:
+ unique_together = ["project", "page"]
+ verbose_name = "Project Page"
+ verbose_name_plural = "Project Pages"
+ db_table = "project_pages"
+ ordering = ("-created_at",)
+
+ def __str__(self):
+ return f"{self.project.name} {self.page.name}"
+
+
+class TeamPage(BaseModel):
+ team = models.ForeignKey(
+ "db.Team", on_delete=models.CASCADE, related_name="team_pages"
+ )
+ page = models.ForeignKey(
+ "db.Page", on_delete=models.CASCADE, related_name="team_pages"
+ )
+ workspace = models.ForeignKey(
+ "db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
+ )
+
+ class Meta:
+ unique_together = ["team", "page"]
+ verbose_name = "Team Page"
+ verbose_name_plural = "Team Pages"
+ db_table = "team_pages"
+ ordering = ("-created_at",)
diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py
index 49fca1323..ba8dbf580 100644
--- a/apiserver/plane/db/models/project.py
+++ b/apiserver/plane/db/models/project.py
@@ -260,6 +260,8 @@ def get_default_views():
}
+# DEPRECATED TODO:
+# used to get the old anchors for the project deploy boards
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py
index 96fbbb967..2a21c55fd 100644
--- a/apiserver/plane/db/models/social_connection.py
+++ b/apiserver/plane/db/models/social_connection.py
@@ -10,7 +10,7 @@ from .base import BaseModel
class SocialLoginConnection(BaseModel):
medium = models.CharField(
max_length=20,
- choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")),
+ choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")),
default=None,
)
last_login_at = models.DateTimeField(default=timezone.now, null=True)
diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py
index c083b631c..2a88df8b6 100644
--- a/apiserver/plane/db/models/user.py
+++ b/apiserver/plane/db/models/user.py
@@ -182,7 +182,7 @@ class Account(TimeAuditModel):
)
provider_account_id = models.CharField(max_length=255)
provider = models.CharField(
- choices=(("google", "Google"), ("github", "Github")),
+ choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")),
)
access_token = models.TextField()
access_token_expired_at = models.DateTimeField(null=True)
diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py
index 8916bd406..3822b531e 100644
--- a/apiserver/plane/db/models/view.py
+++ b/apiserver/plane/db/models/view.py
@@ -102,6 +102,13 @@ class IssueView(WorkspaceBaseModel):
)
sort_order = models.FloatField(default=65535)
logo_props = models.JSONField(default=dict)
+ owned_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="views",
+ )
+ is_locked = models.BooleanField(default=False)
+
class Meta:
verbose_name = "Issue View"
diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py
index f9cd681ec..f10d1ce6d 100644
--- a/apiserver/plane/db/models/workspace.py
+++ b/apiserver/plane/db/models/workspace.py
@@ -5,6 +5,7 @@ from django.db import models
# Module imports
from .base import BaseModel
+from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
ROLE_CHOICES = (
(20, "Owner"),
@@ -112,19 +113,7 @@ 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",
- ]:
+ if value in RESTRICTED_WORKSPACE_SLUGS:
raise ValidationError("Slug is not valid")
diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py
index 1ec09fbb5..8d885083a 100644
--- a/apiserver/plane/license/api/views/instance.py
+++ b/apiserver/plane/license/api/views/instance.py
@@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView):
IS_GOOGLE_ENABLED,
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
+ IS_GITLAB_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "GITHUB_APP_NAME",
"default": os.environ.get("GITHUB_APP_NAME", ""),
},
+ {
+ "key": "IS_GITLAB_ENABLED",
+ "default": os.environ.get("IS_GITLAB_ENABLED", "0"),
+ },
{
"key": "EMAIL_HOST",
"default": os.environ.get("EMAIL_HOST", ""),
@@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView):
# Authentication
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
+ data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py
index 5a6eadc2e..7f61024f9 100644
--- a/apiserver/plane/license/management/commands/configure_instance.py
+++ b/apiserver/plane/license/management/commands/configure_instance.py
@@ -2,7 +2,7 @@
import os
# Django imports
-from django.core.management.base import BaseCommand
+from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import InstanceConfiguration
@@ -15,6 +15,12 @@ class Command(BaseCommand):
from plane.license.utils.encryption import encrypt_data
from plane.license.utils.instance_value import get_configuration_value
+ mandatory_keys = ["SECRET_KEY"]
+
+ for item in mandatory_keys:
+ if not os.environ.get(item):
+ raise CommandError(f"{item} env variable is required.")
+
config_keys = [
# Authentication Settings
{
@@ -59,6 +65,24 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
+ {
+ "key": "GITLAB_HOST",
+ "value": os.environ.get("GITLAB_HOST"),
+ "category": "GITLAB",
+ "is_encrypted": False,
+ },
+ {
+ "key": "GITLAB_CLIENT_ID",
+ "value": os.environ.get("GITLAB_CLIENT_ID"),
+ "category": "GITLAB",
+ "is_encrypted": False,
+ },
+ {
+ "key": "GITLAB_CLIENT_SECRET",
+ "value": os.environ.get("GITLAB_CLIENT_SECRET"),
+ "category": "GITLAB",
+ "is_encrypted": True,
+ },
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
@@ -145,7 +169,7 @@ class Command(BaseCommand):
)
)
- keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"]
+ keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -216,6 +240,46 @@ class Command(BaseCommand):
f"{key} loaded with value from environment variable."
)
)
+ if key == "IS_GITLAB_ENABLED":
+ GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = (
+ get_configuration_value(
+ [
+ {
+ "key": "GITLAB_HOST",
+ "default": os.environ.get(
+ "GITLAB_HOST", "https://gitlab.com"
+ ),
+ },
+ {
+ "key": "GITLAB_CLIENT_ID",
+ "default": os.environ.get(
+ "GITLAB_CLIENT_ID", ""
+ ),
+ },
+ {
+ "key": "GITLAB_CLIENT_SECRET",
+ "default": os.environ.get(
+ "GITLAB_CLIENT_SECRET", ""
+ ),
+ },
+ ]
+ )
+ )
+ if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET):
+ value = "1"
+ else:
+ value = "0"
+ InstanceConfiguration.objects.create(
+ key="IS_GITLAB_ENABLED",
+ value=value,
+ category="AUTHENTICATION",
+ is_encrypted=False,
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{key} loaded with value from environment variable."
+ )
+ )
else:
for key in keys:
self.stdout.write(
diff --git a/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py
new file mode 100644
index 000000000..8d7b9a402
--- /dev/null
+++ b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 4.2.11 on 2024-06-05 13:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("license", "0002_rename_version_instance_current_version_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="changelog",
+ name="title",
+ field=models.CharField(max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="changelog",
+ name="version",
+ field=models.CharField(max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="instance",
+ name="current_version",
+ field=models.CharField(max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="instance",
+ name="latest_version",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="instance",
+ name="namespace",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="instance",
+ name="product",
+ field=models.CharField(default="plane-ce", max_length=255),
+ ),
+ ]
diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py
index ea88ba9bb..0c0581c8b 100644
--- a/apiserver/plane/license/models/instance.py
+++ b/apiserver/plane/license/models/instance.py
@@ -21,15 +21,15 @@ class Instance(BaseModel):
whitelist_emails = models.TextField(blank=True, null=True)
instance_id = models.CharField(max_length=255, unique=True)
license_key = models.CharField(max_length=256, null=True, blank=True)
- current_version = models.CharField(max_length=10)
- latest_version = models.CharField(max_length=10, null=True, blank=True)
+ current_version = models.CharField(max_length=255)
+ latest_version = models.CharField(max_length=255, null=True, blank=True)
product = models.CharField(
- max_length=50, default=ProductTypes.PLANE_CE.value
+ max_length=255, default=ProductTypes.PLANE_CE.value
)
domain = models.TextField(blank=True)
# Instance specifics
last_checked_at = models.DateTimeField()
- namespace = models.CharField(max_length=50, blank=True, null=True)
+ namespace = models.CharField(max_length=255, blank=True, null=True)
# telemetry and support
is_telemetry_enabled = models.BooleanField(default=True)
is_support_required = models.BooleanField(default=True)
@@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel):
class ChangeLog(BaseModel):
"""Change Log model to store the release changelogs made in the application."""
- title = models.CharField(max_length=100)
+ title = models.CharField(max_length=255)
description = models.TextField(blank=True)
- version = models.CharField(max_length=100)
+ version = models.CharField(max_length=255)
tags = models.JSONField(default=list)
release_date = models.DateTimeField(null=True)
is_release_candidate = models.BooleanField(default=False)
diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py
index 40128f9ad..937ff0c2a 100644
--- a/apiserver/plane/settings/common.py
+++ b/apiserver/plane/settings/common.py
@@ -16,6 +16,7 @@ from django.core.management.utils import get_random_secret_key
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
+from corsheaders.defaults import default_headers
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -127,6 +128,8 @@ else:
CORS_ALLOW_ALL_ORIGINS = True
secure_origins = False
+CORS_ALLOW_HEADERS = [*default_headers, "X-API-Key"]
+
# Application Settings
WSGI_APPLICATION = "plane.wsgi.application"
ASGI_APPLICATION = "plane.asgi.application"
diff --git a/apiserver/plane/space/urls/inbox.py b/apiserver/plane/space/urls/inbox.py
index 60de040e2..20ebb3437 100644
--- a/apiserver/plane/space/urls/inbox.py
+++ b/apiserver/plane/space/urls/inbox.py
@@ -10,7 +10,7 @@ from plane.space.views import (
urlpatterns = [
path(
- "workspaces/
+
+
+
diff --git a/apiserver/templates/emails/user/user_activation.html b/apiserver/templates/emails/user/user_activation.html
new file mode 100644
index 000000000..1ec60e955
--- /dev/null
+++ b/apiserver/templates/emails/user/user_activation.html
@@ -0,0 +1,1570 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You've been invited to a Plane
+ project.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Go to project
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apiserver/templates/emails/user/user_deactivation.html b/apiserver/templates/emails/user/user_deactivation.html
new file mode 100644
index 000000000..b6bc7b768
--- /dev/null
+++ b/apiserver/templates/emails/user/user_deactivation.html
@@ -0,0 +1,1571 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Go to Plane
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml
index 10f64fd1c..5a7b1acfc 100644
--- a/deploy/selfhost/docker-compose.yml
+++ b/deploy/selfhost/docker-compose.yml
@@ -147,7 +147,7 @@ services:
plane-redis:
<<: *app-env
- image: redis:7.2.4-alpine
+ image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:
diff --git a/docker-compose-local.yml b/docker-compose-local.yml
index 39c587d2b..aea05f958 100644
--- a/docker-compose-local.yml
+++ b/docker-compose-local.yml
@@ -9,7 +9,7 @@ volumes:
services:
plane-redis:
- image: redis:7.2.4-alpine
+ image: valkey/valkey:7.2.5-alpine
restart: unless-stopped
networks:
- dev_env
diff --git a/docker-compose.yml b/docker-compose.yml
index 879ebb7a4..08d40a85a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -116,7 +116,7 @@ services:
plane-redis:
container_name: plane-redis
- image: redis:7.2.4-alpine
+ image: valkey/valkey:7.2.5-alpine
restart: always
volumes:
- redisdata:/data
diff --git a/package.json b/package.json
index 57d694378..e49884638 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,13 @@
{
"repository": "https://github.com/makeplane/plane.git",
- "version": "0.21.0",
+ "version": "0.22.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"web",
"space",
"admin",
- "packages/editor/*",
+ "packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
@@ -34,10 +34,11 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
- "turbo": "^1.13.2"
+ "turbo": "^2.0.6"
},
"resolutions": {
"@types/react": "18.2.48"
},
- "packageManager": "yarn@1.22.19"
+ "packageManager": "yarn@1.22.22",
+ "name": "plane"
}
diff --git a/packages/constants/auth.ts b/packages/constants/auth.ts
new file mode 100644
index 000000000..59f08a37f
--- /dev/null
+++ b/packages/constants/auth.ts
@@ -0,0 +1,91 @@
+export enum EAuthPageTypes {
+ PUBLIC = "PUBLIC",
+ NON_AUTHENTICATED = "NON_AUTHENTICATED",
+ SET_PASSWORD = "SET_PASSWORD",
+ ONBOARDING = "ONBOARDING",
+ AUTHENTICATED = "AUTHENTICATED",
+}
+
+export enum EAuthModes {
+ SIGN_IN = "SIGN_IN",
+ SIGN_UP = "SIGN_UP",
+}
+
+export enum EAuthSteps {
+ EMAIL = "EMAIL",
+ PASSWORD = "PASSWORD",
+ UNIQUE_CODE = "UNIQUE_CODE",
+}
+
+// TODO: remove this
+export enum EErrorAlertType {
+ BANNER_ALERT = "BANNER_ALERT",
+ INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
+ INLINE_EMAIL = "INLINE_EMAIL",
+ INLINE_PASSWORD = "INLINE_PASSWORD",
+ INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
+}
+
+export enum EAuthErrorCodes {
+ // Global
+ INSTANCE_NOT_CONFIGURED = "5000",
+ INVALID_EMAIL = "5005",
+ EMAIL_REQUIRED = "5010",
+ SIGNUP_DISABLED = "5015",
+ MAGIC_LINK_LOGIN_DISABLED = "5016",
+ PASSWORD_LOGIN_DISABLED = "5018",
+ USER_ACCOUNT_DEACTIVATED = "5019",
+ // Password strength
+ INVALID_PASSWORD = "5020",
+ SMTP_NOT_CONFIGURED = "5025",
+ // Sign Up
+ USER_ALREADY_EXIST = "5030",
+ AUTHENTICATION_FAILED_SIGN_UP = "5035",
+ REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
+ INVALID_EMAIL_SIGN_UP = "5045",
+ INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
+ MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
+ // Sign In
+ USER_DOES_NOT_EXIST = "5060",
+ AUTHENTICATION_FAILED_SIGN_IN = "5065",
+ REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
+ INVALID_EMAIL_SIGN_IN = "5075",
+ INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
+ MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
+ // Both Sign in and Sign up for magic
+ INVALID_MAGIC_CODE_SIGN_IN = "5090",
+ INVALID_MAGIC_CODE_SIGN_UP = "5092",
+ EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
+ EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
+ EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
+ EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
+ // Oauth
+ OAUTH_NOT_CONFIGURED = "5104",
+ GOOGLE_NOT_CONFIGURED = "5105",
+ GITHUB_NOT_CONFIGURED = "5110",
+ GITLAB_NOT_CONFIGURED = "5111",
+ GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
+ GITHUB_OAUTH_PROVIDER_ERROR = "5120",
+ GITLAB_OAUTH_PROVIDER_ERROR = "5121",
+ // Reset Password
+ INVALID_PASSWORD_TOKEN = "5125",
+ EXPIRED_PASSWORD_TOKEN = "5130",
+ // Change password
+ INCORRECT_OLD_PASSWORD = "5135",
+ MISSING_PASSWORD = "5138",
+ INVALID_NEW_PASSWORD = "5140",
+ // set passowrd
+ PASSWORD_ALREADY_SET = "5145",
+ // Admin
+ ADMIN_ALREADY_EXIST = "5150",
+ REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
+ INVALID_ADMIN_EMAIL = "5160",
+ INVALID_ADMIN_PASSWORD = "5165",
+ REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
+ ADMIN_AUTHENTICATION_FAILED = "5175",
+ ADMIN_USER_ALREADY_EXIST = "5180",
+ ADMIN_USER_DOES_NOT_EXIST = "5185",
+ ADMIN_USER_DEACTIVATED = "5190",
+ // Rate limit
+ RATE_LIMIT_EXCEEDED = "5900",
+}
diff --git a/packages/constants/src/index.ts b/packages/constants/index.ts
similarity index 100%
rename from packages/constants/src/index.ts
rename to packages/constants/index.ts
diff --git a/packages/constants/package.json b/packages/constants/package.json
index 28d84c32b..7229217dc 100644
--- a/packages/constants/package.json
+++ b/packages/constants/package.json
@@ -1,10 +1,6 @@
{
"name": "@plane/constants",
- "version": "0.21.0",
+ "version": "0.22.0",
"private": true,
- "main": "./src/index.ts",
- "exports": {
- ".": "./src/index.ts",
- "./*": "./src/*"
- }
+ "main": "./index.ts"
}
diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts
deleted file mode 100644
index c12b63d63..000000000
--- a/packages/constants/src/auth.ts
+++ /dev/null
@@ -1,371 +0,0 @@
-import { ReactNode } from "react";
-import Link from "next/link";
-
-export enum EPageTypes {
- PUBLIC = "PUBLIC",
- NON_AUTHENTICATED = "NON_AUTHENTICATED",
- SET_PASSWORD = "SET_PASSWORD",
- ONBOARDING = "ONBOARDING",
- AUTHENTICATED = "AUTHENTICATED",
-}
-
-export enum EAuthModes {
- SIGN_IN = "SIGN_IN",
- SIGN_UP = "SIGN_UP",
-}
-
-export enum EAuthSteps {
- EMAIL = "EMAIL",
- PASSWORD = "PASSWORD",
- UNIQUE_CODE = "UNIQUE_CODE",
-}
-
-// TODO: remove this
-export enum EErrorAlertType {
- BANNER_ALERT = "BANNER_ALERT",
- INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
- INLINE_EMAIL = "INLINE_EMAIL",
- INLINE_PASSWORD = "INLINE_PASSWORD",
- INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
-}
-
-export enum EAuthErrorCodes {
- // Global
- INSTANCE_NOT_CONFIGURED = "5000",
- INVALID_EMAIL = "5005",
- EMAIL_REQUIRED = "5010",
- SIGNUP_DISABLED = "5015",
- MAGIC_LINK_LOGIN_DISABLED = "5017",
- PASSWORD_LOGIN_DISABLED = "5019",
- SMTP_NOT_CONFIGURED = "5025",
- // Password strength
- INVALID_PASSWORD = "5020",
- // Sign Up
- USER_ACCOUNT_DEACTIVATED = "5019",
- USER_ALREADY_EXIST = "5030",
- AUTHENTICATION_FAILED_SIGN_UP = "5035",
- REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
- INVALID_EMAIL_SIGN_UP = "5045",
- INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
- MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
- // Sign In
- USER_DOES_NOT_EXIST = "5060",
- AUTHENTICATION_FAILED_SIGN_IN = "5065",
- REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
- INVALID_EMAIL_SIGN_IN = "5075",
- INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
- MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
- // Both Sign in and Sign up for magic
- INVALID_MAGIC_CODE = "5090",
- EXPIRED_MAGIC_CODE = "5095",
- EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100",
- // Oauth
- GOOGLE_NOT_CONFIGURED = "5105",
- GITHUB_NOT_CONFIGURED = "5110",
- GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
- GITHUB_OAUTH_PROVIDER_ERROR = "5120",
- // Reset Password
- INVALID_PASSWORD_TOKEN = "5125",
- EXPIRED_PASSWORD_TOKEN = "5130",
- // Change password
- INCORRECT_OLD_PASSWORD = "5135",
- MISSING_PASSWORD= "5138",
- INVALID_NEW_PASSWORD = "5140",
- // set passowrd
- PASSWORD_ALREADY_SET = "5145",
- // Admin
- ADMIN_ALREADY_EXIST = "5150",
- REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
- INVALID_ADMIN_EMAIL = "5160",
- INVALID_ADMIN_PASSWORD = "5165",
- REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
- ADMIN_AUTHENTICATION_FAILED = "5175",
- ADMIN_USER_ALREADY_EXIST = "5180",
- ADMIN_USER_DOES_NOT_EXIST = "5185",
-}
-
-export type TAuthErrorInfo = {
- type: EErrorAlertType;
- code: EAuthErrorCodes;
- title: string;
- message: ReactNode;
-};
-
-const errorCodeMessages: {
- [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
-} = {
- // global
- [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: {
- title: `Instance not configured`,
- message: () => `Instance not configured. Please contact your administrator.`,
- },
- [EAuthErrorCodes.SIGNUP_DISABLED]: {
- title: `Sign up disabled`,
- message: () => `Sign up disabled. Please contact your administrator.`,
- },
- [EAuthErrorCodes.INVALID_PASSWORD]: {
- title: `Invalid password`,
- message: () => `Invalid password. Please try again.`,
- },
- [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: {
- title: `SMTP not configured`,
- message: () => `SMTP not configured. Please contact your administrator.`,
- },
-
- // email check in both sign up and sign in
- [EAuthErrorCodes.INVALID_EMAIL]: {
- title: `Invalid email`,
- message: () => `Invalid email. Please try again.`,
- },
- [EAuthenticationErrorCodes.EMAIL_REQUIRED]: {
- title: `Email required`,
- message: () => `Email required. Please try again.`,
- },
-
- // sign up
- [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: {
- title: `User already exists`,
- message: (email = undefined) => (
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reactivate account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+
+
+ Â
+
+
+ Â
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+ Â
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- {section}
-
- {item.identifier}
-
- {props.node.attrs.project_identifier}-{props.node.attrs.sequence_id}
-
-
- {tooltipHeading}
-
- )}
- {tooltipContent}
-