diff --git a/.github/actions/build-push-ce/action.yml b/.github/actions/build-push-ce/action.yml new file mode 100644 index 000000000..acf06982d --- /dev/null +++ b/.github/actions/build-push-ce/action.yml @@ -0,0 +1,126 @@ +name: "Build and Push Docker Image" +description: "Reusable action for building and pushing Docker images" +inputs: + docker-username: + description: "The Dockerhub username" + required: true + docker-token: + description: "The Dockerhub Token" + required: true + + # Docker Image Options + docker-image-owner: + description: "The owner of the Docker image" + required: true + docker-image-name: + description: "The name of the Docker image" + required: true + build-context: + description: "The build context" + required: true + default: "." + dockerfile-path: + description: "The path to the Dockerfile" + required: true + build-args: + description: "The build arguments" + required: false + default: "" + + # Buildx Options + buildx-driver: + description: "Buildx driver" + required: true + default: "docker-container" + buildx-version: + description: "Buildx version" + required: true + default: "latest" + buildx-platforms: + description: "Buildx platforms" + required: true + default: "linux/amd64" + buildx-endpoint: + description: "Buildx endpoint" + required: true + default: "default" + + # Release Build Options + build-release: + description: "Flag to publish release" + required: false + default: "false" + build-prerelease: + description: "Flag to publish prerelease" + required: false + default: "false" + release-version: + description: "The release version" + required: false + default: "latest" + +runs: + using: "composite" + steps: + - name: Set Docker Tag + shell: bash + env: + IMG_OWNER: ${{ inputs.docker-image-owner }} + IMG_NAME: ${{ inputs.docker-image-name }} + BUILD_RELEASE: ${{ inputs.build-release }} + IS_PRERELEASE: ${{ inputs.build-prerelease }} + REL_VERSION: ${{ inputs.release-version }} + run: | + FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g') + + if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then + semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" + if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then + echo "Invalid Release Version Format : ${{ env.REL_VERSION }}" + echo "Please provide a valid SemVer version" + echo "e.g. v1.2.3 or v1.2.3-alpha-1" + echo "Exiting the build process" + exit 1 # Exit with status 1 to fail the step + fi + + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }} + + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable + fi + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest + else + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION} + fi + + echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ inputs.docker-username }} + password: ${{ inputs.docker-token}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ inputs.buildx-driver }} + version: ${{ inputs.buildx-version }} + endpoint: ${{ inputs.buildx-endpoint }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5.1.0 + with: + context: ${{ inputs.build-context }} + file: ${{ inputs.dockerfile-path }} + platforms: ${{ inputs.buildx-platforms }} + tags: ${{ env.DOCKER_TAGS }} + push: true + build-args: ${{ inputs.build-args }} + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ inputs.docker-username }} + DOCKER_PASSWORD: ${{ inputs.docker-token }} \ No newline at end of file diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml index 301fd8766..3fb2958f1 100644 --- a/.github/workflows/build-aio-base.yml +++ b/.github/workflows/build-aio-base.yml @@ -83,7 +83,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Build and Push to Docker Hub - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v6.9.0 with: context: ./aio file: ./aio/Dockerfile-base-full @@ -124,7 +124,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Build and Push to Docker Hub - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v6.9.0 with: context: ./aio file: ./aio/Dockerfile-base-slim diff --git a/.github/workflows/build-aio-branch.yml b/.github/workflows/build-aio-branch.yml index 8e28fe0d4..a3a2f35fa 100644 --- a/.github/workflows/build-aio-branch.yml +++ b/.github/workflows/build-aio-branch.yml @@ -128,7 +128,7 @@ jobs: uses: actions/checkout@v4 - name: Build and Push to Docker Hub - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v6.9.0 with: context: . file: ./aio/Dockerfile-app @@ -188,7 +188,7 @@ jobs: uses: actions/checkout@v4 - name: Build and Push to Docker Hub - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v6.9.0 with: context: . file: ./aio/Dockerfile-app diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index d3e501a44..1e06c1bd3 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,29 +1,42 @@ -name: Branch Build +name: Branch Build CE on: workflow_dispatch: inputs: + build_type: + description: "Type of build to run" + required: true + type: choice + default: "Build" + options: + - "Build" + - "Release" + releaseVersion: + description: "Release Version" + type: string + default: v0.0.0 + isPrerelease: + description: "Is Pre-release" + type: boolean + default: false + required: true arm64: description: "Build for ARM64 architecture" required: false default: false type: boolean - push: - branches: - - master - - preview - release: - types: [released, prereleased] env: - TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ github.ref_name }} ARM64_BUILD: ${{ github.event.inputs.arm64 }} - IS_PRERELEASE: ${{ github.event.release.prerelease }} + BUILD_TYPE: ${{ github.event.inputs.build_type }} + RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} + IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }} jobs: branch_build_setup: name: Build Setup - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} @@ -36,13 +49,24 @@ jobs: build_space: ${{ steps.changed_files.outputs.space_any_changed }} build_web: ${{ steps.changed_files.outputs.web_any_changed }} build_live: ${{ steps.changed_files.outputs.live_any_changed }} - flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} + + dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }} + dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }} + dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }} + dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }} + dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }} + dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }} + + build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}} + build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }} + build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }} + release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }} steps: - id: set_env_variables name: Set Environment Variables run: | - if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then + if [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ env.BUILD_TYPE }}" == "Release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT @@ -53,9 +77,43 @@ jobs: echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT echo "BUILDX_ENDPOINT=" >> $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 + BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" |sed 's/[^a-zA-Z0-9.-]//g') + echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT + + echo "DH_IMG_WEB=plane-frontend" >> $GITHUB_OUTPUT + echo "DH_IMG_SPACE=plane-space" >> $GITHUB_OUTPUT + echo "DH_IMG_ADMIN=plane-admin" >> $GITHUB_OUTPUT + echo "DH_IMG_LIVE=plane-live" >> $GITHUB_OUTPUT + echo "DH_IMG_BACKEND=plane-backend" >> $GITHUB_OUTPUT + echo "DH_IMG_PROXY=plane-proxy" >> $GITHUB_OUTPUT + + echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT + BUILD_RELEASE=false + BUILD_PRERELEASE=false + RELVERSION="latest" + + if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then + FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g') + echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT + + semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" + if [[ ! $FLAT_RELEASE_VERSION =~ $semver_regex ]]; then + echo "Invalid Release Version Format : $FLAT_RELEASE_VERSION" + echo "Please provide a valid SemVer version" + echo "e.g. v1.2.3 or v1.2.3-alpha-1" + echo "Exiting the build process" + exit 1 # Exit with status 1 to fail the step + fi + BUILD_RELEASE=true + RELVERSION=$FLAT_RELEASE_VERSION + + if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then + BUILD_PRERELEASE=true + fi + fi + echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT + echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT + echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT - id: checkout_files name: Checkout Files @@ -73,24 +131,24 @@ jobs: admin: - admin/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" space: - space/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" web: - web/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" live: - live/** - packages/** @@ -99,338 +157,225 @@ jobs: - 'tsconfig.json' - 'turbo.json' - branch_build_push_web: - if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} - name: Build-Push Web Docker Image - runs-on: ubuntu-20.04 - needs: [branch_build_setup] - env: - FRONTEND_TAG: makeplane/plane-frontend:${{ needs.branch_build_setup.outputs.flat_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 Frontend Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-frontend:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-frontend:latest - else - TAG=${{ env.FRONTEND_TAG }} - fi - echo "FRONTEND_TAG=${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 Frontend to Docker Container Registry - uses: docker/build-push-action@v5.1.0 - with: - context: . - file: ./web/Dockerfile.web - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.FRONTEND_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - branch_build_push_admin: - if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Admin Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - ADMIN_TAG: makeplane/plane-admin:${{ needs.branch_build_setup.outputs.flat_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 Admin Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-admin:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-admin:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-admin:latest - else - TAG=${{ env.ADMIN_TAG }} - fi - echo "ADMIN_TAG=${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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v5.1.0 + - name: Admin Build and Push + uses: ./.github/actions/build-push-ce with: - context: . - file: ./admin/Dockerfile.admin - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.ADMIN_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} + build-context: . + dockerfile-path: ./admin/Dockerfile.admin + 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 }} + + branch_build_push_web: + if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Web Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Web Build and Push + uses: ./.github/actions/build-push-ce + with: + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} + build-context: . + dockerfile-path: ./web/Dockerfile.web + 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 }} branch_build_push_space: - if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Space Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - SPACE_TAG: makeplane/plane-space:${{ needs.branch_build_setup.outputs.flat_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 Space Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-space:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-space:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-space:latest - else - TAG=${{ env.SPACE_TAG }} - fi - echo "SPACE_TAG=${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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Space to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Space Build and Push + uses: ./.github/actions/build-push-ce with: - context: . - file: ./space/Dockerfile.space - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.SPACE_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - branch_build_push_apiserver: - if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} - name: Build-Push API Server Docker Image - runs-on: ubuntu-20.04 - needs: [branch_build_setup] - env: - BACKEND_TAG: makeplane/plane-backend:${{ needs.branch_build_setup.outputs.flat_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 Backend Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-backend:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-backend:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-backend:latest - else - TAG=${{ env.BACKEND_TAG }} - fi - echo "BACKEND_TAG=${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 Backend to Docker Hub - uses: docker/build-push-action@v5.1.0 - with: - context: ./apiserver - file: ./apiserver/Dockerfile.api - platforms: ${{ env.BUILDX_PLATFORMS }} - push: true - tags: ${{ env.BACKEND_TAG }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} + build-context: . + dockerfile-path: ./space/Dockerfile.space + 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 }} branch_build_push_live: - if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Live Collaboration Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - LIVE_TAG: makeplane/plane-live:${{ needs.branch_build_setup.outputs.flat_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 Live Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-live:${{ github.event.release.tag_name }} - if [ "${{ github.event.release.prerelease }}" != "true" ]; then - TAG=${TAG},makeplane/plane-live:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-live:latest - else - TAG=${{ env.LIVE_TAG }} - fi - echo "LIVE_TAG=${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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Live Server to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Live Build and Push + uses: ./.github/actions/build-push-ce with: - context: . - file: ./live/Dockerfile.live - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.LIVE_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} + build-context: . + dockerfile-path: ./live/Dockerfile.live + 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 }} + + branch_build_push_apiserver: + if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push API Server Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Backend Build and Push + uses: ./.github/actions/build-push-ce + with: + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }} + build-context: ./apiserver + dockerfile-path: ./apiserver/Dockerfile.api + 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 }} branch_build_push_proxy: - if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Proxy Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - PROXY_TAG: makeplane/plane-proxy:${{ needs.branch_build_setup.outputs.flat_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 Proxy Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-proxy:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-proxy:latest - else - TAG=${{ env.PROXY_TAG }} - fi - echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Proxy Build and Push + uses: ./.github/actions/build-push-ce with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }} + build-context: ./nginx + dockerfile-path: ./nginx/Dockerfile + 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 }} - - 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 + attach_assets_to_build: + if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }} + name: Attach Assets to Build + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - name: Checkout uses: actions/checkout@v4 - - name: Build and Push Plane-Proxy to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Update Assets + run: | + cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh + + - name: Attach Assets + id: attach_assets + uses: actions/upload-artifact@v4 with: - context: ./nginx - file: ./nginx/Dockerfile - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.PROXY_TAG }} - push: true + name: selfhost-assets + retention-days: 2 + path: | + ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/restore.sh + ${{ github.workspace }}/deploy/selfhost/docker-compose.yml + ${{ github.workspace }}/deploy/selfhost/variables.env + + publish_release: + if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} + name: Build Release + runs-on: ubuntu-20.04 + needs: + [ + branch_build_setup, + branch_build_push_admin, + branch_build_push_web, + branch_build_push_space, + branch_build_push_live, + branch_build_push_apiserver, + branch_build_push_proxy, + attach_assets_to_build, + ] + env: + REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Assets + run: | + cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2.1.0 env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + with: + tag_name: ${{ env.REL_VERSION }} + name: ${{ env.REL_VERSION }} + draft: false + prerelease: ${{ env.IS_PRERELEASE }} + generate_release_notes: true + files: | + ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/restore.sh + ${{ github.workspace }}/deploy/selfhost/docker-compose.yml + ${{ github.workspace }}/deploy/selfhost/variables.env diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dbfd81168..2bcbf557f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -46,7 +46,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -59,6 +59,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index 0c71564e1..311014cf8 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -79,7 +79,7 @@ jobs: uses: actions/checkout@v4 - name: Build and Push to Docker Hub - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v6.9.0 with: context: . file: ./aio/Dockerfile-app diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/sync-repo-pr.yml similarity index 87% rename from .github/workflows/create-sync-pr.yml rename to .github/workflows/sync-repo-pr.yml index 46f6365fd..548ccbf42 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/sync-repo-pr.yml @@ -8,21 +8,20 @@ on: env: CURRENT_BRANCH: ${{ github.ref_name }} - TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop + TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows - REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - Create_PR: + create_pull_request: runs-on: ubuntu-latest permissions: pull-requests: write contents: write steps: - name: Checkout code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch all history for all branches and tags @@ -48,6 +47,6 @@ jobs: echo "Pull Request already exists: $PR_EXISTS" else echo "Creating new pull request" - PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "") + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "") echo "Pull Request created: $PR_URL" fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/sync-repo.yml similarity index 92% rename from .github/workflows/repo-sync.yml rename to .github/workflows/sync-repo.yml index 2c211cf31..1f13995de 100644 --- a/.github/workflows/repo-sync.yml +++ b/.github/workflows/sync-repo.yml @@ -17,7 +17,7 @@ jobs: contents: read steps: - name: Checkout Code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 with: persist-credentials: false fetch-depth: 0 @@ -35,9 +35,8 @@ jobs: env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | - RUN_ID="${{ github.run_id }}" TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" - TARGET_BRANCH="sync/${RUN_ID}" + TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index 510566e80..4258a99fb 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -121,7 +121,12 @@ export const InstanceAIForm: FC = (props) => {
-
If you have a preferred AI models vendor, please get in touch with us.
+
+ If you have a preferred AI models vendor, please get in{" "} + + touch with us. + +
diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index afab9a3c5..23ba9ccd1 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -195,7 +195,7 @@ export const InstanceGithubConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/gitlab/form.tsx b/admin/app/authentication/gitlab/form.tsx index 2d5782e10..1cc9794d3 100644 --- a/admin/app/authentication/gitlab/form.tsx +++ b/admin/app/authentication/gitlab/form.tsx @@ -191,7 +191,7 @@ export const InstanceGitlabConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx index cf5797895..61fe7af62 100644 --- a/admin/app/authentication/google/form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -192,7 +192,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d37b35978..dc6aa6a6d 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -60,7 +60,7 @@ const InstanceAuthenticationPage = observer(() => {
Manage authentication modes for your instance
- Configure authentication modes for your team and restrict sign ups to be invite only. + Configure authentication modes for your team and restrict sign-ups to be invite only.
@@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => { { - Boolean(parseInt(enableSignUpConfig)) === true - ? updateConfig("ENABLE_SIGNUP", "0") - : updateConfig("ENABLE_SIGNUP", "1"); + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } }} size="sm" disabled={isSubmitting} @@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
-
Authentication modes
+
Available authentication modes
) : ( diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx index 2cc24fc89..73a1af174 100644 --- a/admin/app/email/email-config-form.tsx +++ b/admin/app/email/email-config-form.tsx @@ -72,7 +72,7 @@ export const InstanceEmailForm: FC = (props) => { { key: "EMAIL_FROM", type: "text", - label: "Sender email address", + label: "Sender's email address", description: "This is the email address your users will see when getting emails from this instance. You will need to verify this address.", placeholder: "no-reply@projectplane.so", @@ -174,12 +174,12 @@ export const InstanceEmailForm: FC = (props) => {
-
+
-
Authentication (optional)
+
Authentication
- We recommend setting up a username password for your SMTP server + This is optional, but we recommend setting up a username and a password for your SMTP server.
diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx index 4422ee91f..09aac8b45 100644 --- a/admin/app/general/form.tsx +++ b/admin/app/general/form.tsx @@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC = observer(
- Allow Plane to collect anonymous usage events + Let Plane collect anonymous usage data
- We collect usage events without any PII to analyse and improve Plane.{" "} + No PII is collected.This anonymized data is used to understand how you use Plane and build new features + in line with{" "} - Know more. + our Telemetry Policy.
diff --git a/admin/app/general/intercom.tsx b/admin/app/general/intercom.tsx index aaeacfc0f..37f7e3071 100644 --- a/admin/app/general/intercom.tsx +++ b/admin/app/general/intercom.tsx @@ -60,9 +60,9 @@ export const IntercomConfig: FC = observer((props) => {
-
Talk to Plane
+
Chat with us
- Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off + Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off automatically.
diff --git a/admin/app/workspace/create/form.tsx b/admin/app/workspace/create/form.tsx new file mode 100644 index 000000000..958f53153 --- /dev/null +++ b/admin/app/workspace/create/form.tsx @@ -0,0 +1,214 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// constants +import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; +// types +import { IWorkspace } from "@plane/types"; +// components +import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { WEB_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); + +export const WorkspaceCreateForm = () => { + // router + const router = useRouter(); + // states + const [slugError, setSlugError] = useState(false); + const [invalidSlug, setInvalidSlug] = useState(false); + const [defaultValues, setDefaultValues] = useState>({ + name: "", + slug: "", + organization_size: "", + }); + // store hooks + const { createWorkspace } = useWorkspace(); + // form info + const { + handleSubmit, + control, + setValue, + getValues, + formState: { errors, isSubmitting, isValid }, + } = useForm({ defaultValues, mode: "onChange" }); + // derived values + const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/"); + + const handleCreateWorkspace = async (formData: IWorkspace) => { + await workspaceService + .workspaceSlugCheck(formData.slug) + .then(async (res) => { + if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(false); + await createWorkspace(formData) + .then(async () => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Workspace created successfully.", + }); + router.push(`/workspace`); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Workspace could not be created. Please try again.", + }); + }); + } else setSlugError(true); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Some error occurred while creating workspace. Please try again.", + }); + }); + }; + + useEffect( + () => () => { + // when the component unmounts set the default values to whatever user typed in + setDefaultValues(getValues()); + }, + [getValues, setDefaultValues] + ); + + return ( +
+
+
+

Name your workspace

+
+ + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, + maxLength: { + value: 80, + message: "Limit your name to 80 characters.", + }, + }} + render={({ field: { value, ref, onChange } }) => ( + { + onChange(e.target.value); + setValue("name", e.target.value); + setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), { + shouldValidate: true, + }); + }} + ref={ref} + hasError={Boolean(errors.name)} + placeholder="Something familiar and recognizable is always best." + className="w-full" + /> + )} + /> + {errors?.name?.message} +
+
+
+

Set your workspace's URL

+
+ {workspaceBaseURL} + ( + { + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); + onChange(e.target.value.toLowerCase()); + }} + ref={ref} + hasError={Boolean(errors.slug)} + placeholder="workspace-name" + className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" + /> + )} + /> +
+ {slugError &&

This URL is taken. Try something else.

} + {invalidSlug && ( +

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

+ )} + {errors.slug && {errors.slug.message}} +
+
+

How many people will use this workspace?

+
+ ( + c === value) ?? ( + Select a range + ) + } + buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + input + optionsClassName="w-full" + > + {ORGANIZATION_SIZE.map((item) => ( + + {item} + + ))} + + )} + /> + {errors.organization_size && ( + {errors.organization_size.message} + )} +
+
+
+
+ + + Go back + +
+
+ ); +}; diff --git a/admin/app/workspace/create/page.tsx b/admin/app/workspace/create/page.tsx new file mode 100644 index 000000000..0186286a7 --- /dev/null +++ b/admin/app/workspace/create/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { WorkspaceCreateForm } from "./form"; + +const WorkspaceCreatePage = observer(() => ( +
+
+
Create a new workspace on this instance.
+
+ You will need to invite users from Workspace Settings after you create this workspace. +
+
+
+ +
+
+)); + +export default WorkspaceCreatePage; diff --git a/admin/app/workspace/layout.tsx b/admin/app/workspace/layout.tsx new file mode 100644 index 000000000..9f2a63c67 --- /dev/null +++ b/admin/app/workspace/layout.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +// layouts +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "Workspace Management - Plane Web", +}; + +export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx new file mode 100644 index 000000000..ef8a3c42d --- /dev/null +++ b/admin/app/workspace/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +import { Loader as LoaderIcon } from "lucide-react"; +// types +import { TInstanceConfigurationKeys } from "@plane/types"; +// ui +import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; +// components +import { WorkspaceListItem } from "@/components/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; + +const WorkspaceManagementPage = observer(() => { + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // store + const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); + const { + workspaceIds, + loader: workspaceLoader, + paginationInfo, + fetchWorkspaces, + fetchNextWorkspaces, + } = useWorkspace(); + // derived values + const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? ""; + const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined; + + // fetch data + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces()); + + const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving configuration", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + return ( +
+
+
+
Workspaces on this instance
+
+ See all workspaces and control who can create them. +
+
+
+
+
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new + workspaces. +
+
+
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance{" "} + â€ĸ {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )} +
+
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member. +
+
+
+ + Create workspace + +
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )} +
+
+
+ ); +}); + +export default WorkspaceManagementPage; diff --git a/admin/ce/components/common/upgrade-button.tsx b/admin/ce/components/common/upgrade-button.tsx index aa3c95fdb..c2b264bae 100644 --- a/admin/ce/components/common/upgrade-button.tsx +++ b/admin/ce/components/common/upgrade-button.tsx @@ -9,8 +9,8 @@ import { getButtonStyling } from "@plane/ui"; import { cn } from "@/helpers/common.helper"; export const UpgradeButton: React.FC = () => ( - - Available on One + + Upgrade ); diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx index abba68e3e..10d5cbd0d 100644 --- a/admin/core/components/admin-sidebar/help-section.tsx +++ b/admin/core/components/admin-sidebar/help-section.tsx @@ -52,13 +52,13 @@ export const HelpSection: FC = observer(() => { )} >
- + - {!isSidebarCollapsed && "Redirect to plane"} + {!isSidebarCollapsed && "Redirect to Plane"} diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index b5a7b4f15..e0741f7c4 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -5,11 +5,13 @@ import { observer } from "mobx-react"; import { useTheme as useNextTheme } from "next-themes"; import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; +// plane ui import { Avatar } from "@plane/ui"; -// hooks -import { API_BASE_URL, cn } from "@/helpers/common.helper"; -import { useTheme, useUser } from "@/hooks/store"; // helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useTheme, useUser } from "@/hooks/store"; // services import { AuthService } from "@/services/auth.service"; @@ -122,7 +124,7 @@ export const SidebarDropdown = observer(() => { { return "Github"; case "gitlab": return "GitLab"; + case "workspace": + return "Workspace"; + case "create": + return "Create"; default: return pathName.toUpperCase(); } diff --git a/admin/core/components/new-user-popup.tsx b/admin/core/components/new-user-popup.tsx index 9fc3938ff..8e1570781 100644 --- a/admin/core/components/new-user-popup.tsx +++ b/admin/core/components/new-user-popup.tsx @@ -1,13 +1,13 @@ "use client"; import React from "react"; +import { resolveGeneralTheme } from "helpers/common.helper"; import { observer } from "mobx-react"; import Image from "next/image"; +import Link from "next/link"; import { useTheme as nextUseTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/ui"; -// helpers -import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; // hooks import { useTheme } from "@/hooks/store"; // icons @@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => { // theme const { resolvedTheme } = nextUseTheme(); - const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); - if (!isNewUserPopup) return <>; return (
@@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
Create workspace
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first - workspace, you will need to login again. + workspace.
- + Create workspace - + diff --git a/admin/core/components/workspace/index.ts b/admin/core/components/workspace/index.ts new file mode 100644 index 000000000..24950c4f2 --- /dev/null +++ b/admin/core/components/workspace/index.ts @@ -0,0 +1 @@ +export * from "./list-item"; diff --git a/admin/core/components/workspace/list-item.tsx b/admin/core/components/workspace/list-item.tsx new file mode 100644 index 000000000..e0a96a6ef --- /dev/null +++ b/admin/core/components/workspace/list-item.tsx @@ -0,0 +1,81 @@ +import { observer } from "mobx-react"; +import { ExternalLink } from "lucide-react"; +// helpers +import { Tooltip } from "@plane/ui"; +import { WEB_BASE_URL } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +type TWorkspaceListItemProps = { + workspaceId: string; +}; + +export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => { + // store hooks + const { getWorkspaceById } = useWorkspace(); + // derived values + const workspace = getWorkspaceById(workspaceId); + + if (!workspace) return null; + return ( + +
+ + {workspace?.logo_url && workspace.logo_url !== "" ? ( + Workspace Logo + ) : ( + (workspace?.name?.[0] ?? "...") + )} + +
+
+

{workspace.name}

/ + +

[{workspace.slug}]

+
+
+ {workspace.owner.email && ( +
+

Owned by:

+

{workspace.owner.email}

+
+ )} +
+ {workspace.total_projects !== null && ( + +

Total projects:

+

{workspace.total_projects}

+
+ )} + {workspace.total_members !== null && ( + <> + â€ĸ + +

Total members:

+

{workspace.total_members}

+
+ + )} +
+
+
+
+ +
+
+ ); +}); diff --git a/admin/core/hooks/store/index.ts b/admin/core/hooks/store/index.ts index 7447064da..ed1781299 100644 --- a/admin/core/hooks/store/index.ts +++ b/admin/core/hooks/store/index.ts @@ -1,3 +1,4 @@ export * from "./use-theme"; export * from "./use-instance"; export * from "./use-user"; +export * from "./use-workspace"; diff --git a/admin/core/hooks/store/use-workspace.tsx b/admin/core/hooks/store/use-workspace.tsx new file mode 100644 index 000000000..e3bde92d5 --- /dev/null +++ b/admin/core/hooks/store/use-workspace.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { IWorkspaceStore } from "@/store/workspace.store"; + +export const useWorkspace = (): IWorkspaceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider"); + return context.workspace; +}; diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts new file mode 100644 index 000000000..81ba36a6f --- /dev/null +++ b/admin/core/services/workspace.service.ts @@ -0,0 +1,53 @@ +// types +import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class WorkspaceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + /** + * @description Fetches all workspaces + * @returns Promise + */ + async getWorkspaces(nextPageCursor?: string): Promise { + return this.get("/api/instances/workspaces/", { + cursor: nextPageCursor, + }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Checks if a slug is available + * @param slug - string + * @returns Promise + */ + async workspaceSlugCheck(slug: string): Promise { + const params = new URLSearchParams({ slug }); + return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + async createWorkspace(data: IWorkspace): Promise { + return this.post("/api/instances/workspaces/", data) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/admin/core/store/root.store.ts b/admin/core/store/root.store.ts index 4b25bcc68..8c53061ab 100644 --- a/admin/core/store/root.store.ts +++ b/admin/core/store/root.store.ts @@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react"; import { IInstanceStore, InstanceStore } from "./instance.store"; import { IThemeStore, ThemeStore } from "./theme.store"; import { IUserStore, UserStore } from "./user.store"; +import { IWorkspaceStore, WorkspaceStore } from "./workspace.store"; enableStaticRendering(typeof window === "undefined"); @@ -10,17 +11,20 @@ export abstract class CoreRootStore { theme: IThemeStore; instance: IInstanceStore; user: IUserStore; + workspace: IWorkspaceStore; constructor() { this.theme = new ThemeStore(this); this.instance = new InstanceStore(this); this.user = new UserStore(this); + this.workspace = new WorkspaceStore(this); } hydrate(initialData: any) { this.theme.hydrate(initialData.theme); this.instance.hydrate(initialData.instance); this.user.hydrate(initialData.user); + this.workspace.hydrate(initialData.workspace); } resetOnSignOut() { @@ -28,5 +32,6 @@ export abstract class CoreRootStore { this.instance = new InstanceStore(this); this.user = new UserStore(this); this.theme = new ThemeStore(this); + this.workspace = new WorkspaceStore(this); } } diff --git a/admin/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts new file mode 100644 index 000000000..f892e14f0 --- /dev/null +++ b/admin/core/store/workspace.store.ts @@ -0,0 +1,150 @@ +import set from "lodash/set"; +import { action, observable, runInAction, makeObservable, computed } from "mobx"; +import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; +// services +import { WorkspaceService } from "@/services/workspace.service"; +// root store +import { CoreRootStore } from "@/store/root.store"; + +export interface IWorkspaceStore { + // observables + loader: TLoader; + workspaces: Record; + paginationInfo: TPaginationInfo | undefined; + // computed + workspaceIds: string[]; + // helper actions + hydrate: (data: Record) => void; + getWorkspaceById: (workspaceId: string) => IWorkspace | undefined; + // fetch actions + fetchWorkspaces: () => Promise; + fetchNextWorkspaces: () => Promise; + // curd actions + createWorkspace: (data: IWorkspace) => Promise; +} + +export class WorkspaceStore implements IWorkspaceStore { + // observables + loader: TLoader = "init-loader"; + workspaces: Record = {}; + paginationInfo: TPaginationInfo | undefined = undefined; + // services + workspaceService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + loader: observable, + workspaces: observable, + paginationInfo: observable, + // computed + workspaceIds: computed, + // helper actions + hydrate: action, + getWorkspaceById: action, + // fetch actions + fetchWorkspaces: action, + fetchNextWorkspaces: action, + // curd actions + createWorkspace: action, + }); + this.workspaceService = new WorkspaceService(); + } + + // computed + get workspaceIds() { + return Object.keys(this.workspaces); + } + + // helper actions + /** + * @description Hydrates the workspaces + * @param data - Record + */ + hydrate = (data: Record) => { + if (data) this.workspaces = data; + }; + + /** + * @description Gets a workspace by id + * @param workspaceId - string + * @returns IWorkspace | undefined + */ + getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId]; + + // fetch actions + /** + * @description Fetches all workspaces + * @returns Promise<> + */ + fetchWorkspaces = async (): Promise => { + try { + if (this.workspaceIds.length > 0) { + this.loader = "mutation"; + } else { + this.loader = "init-loader"; + } + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + /** + * @description Fetches the next page of workspaces + * @returns Promise + */ + fetchNextWorkspaces = async (): Promise => { + if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return []; + try { + this.loader = "pagination"; + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching next workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + // curd actions + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + createWorkspace = async (data: IWorkspace): Promise => { + try { + this.loader = "mutation"; + const workspace = await this.workspaceService.createWorkspace(data); + runInAction(() => { + set(this.workspaces, [workspace.id], workspace); + }); + return workspace; + } catch (error) { + console.error("Error creating workspace", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; +} diff --git a/admin/helpers/file.helper.ts b/admin/helpers/file.helper.ts new file mode 100644 index 000000000..6e1f54636 --- /dev/null +++ b/admin/helpers/file.helper.ts @@ -0,0 +1,14 @@ +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; diff --git a/admin/helpers/string.helper.ts b/admin/helpers/string.helper.ts new file mode 100644 index 000000000..a48508118 --- /dev/null +++ b/admin/helpers/string.helper.ts @@ -0,0 +1,21 @@ +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/admin/package.json b/admin/package.json index 023636a25..e4026da02 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.23.1", + "version": "0.24.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -22,7 +22,6 @@ "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", "axios": "^1.7.4", - "js-cookie": "^3.0.5", "lodash": "^4.17.21", "lucide-react": "^0.356.0", "mobx": "^6.12.0", @@ -41,9 +40,8 @@ "devDependencies": { "@plane/eslint-config": "*", "@plane/typescript-config": "*", - "@types/js-cookie": "^3.0.6", "@types/node": "18.16.1", - "@types/react": "^18.2.48", + "@types/react": "^18.3.11", "@types/react-dom": "^18.2.18", "@types/uuid": "^9.0.8", "@types/zxcvbn": "^4.4.4", diff --git a/apiserver/.env.example b/apiserver/.env.example index 733e448d6..33ef5c4cd 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -57,5 +57,6 @@ ADMIN_BASE_URL= SPACE_BASE_URL= APP_BASE_URL= + # Hard delete files after days -HARD_DELETE_AFTER_DAYS=60 +HARD_DELETE_AFTER_DAYS=60 \ No newline at end of file diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index cdb7e41ed..97a2b2d41 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -4,6 +4,7 @@ FROM python:3.12.5-alpine AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/ WORKDIR /code diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index 3cde896b9..c81966de4 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -4,6 +4,7 @@ FROM python:3.12.5-alpine AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/ RUN apk --no-cache add \ "bash~=5.2" \ diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index 328b9db2b..69eb2f1df 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -26,9 +26,7 @@ def update_description(): updated_issues.append(issue) Issue.objects.bulk_update( - updated_issues, - ["description_html", "description_stripped"], - batch_size=100, + updated_issues, ["description_html", "description_stripped"], batch_size=100 ) print("Success") except Exception as e: @@ -42,9 +40,7 @@ def update_comments(): updated_issue_comments = [] for issue_comment in issue_comments: - issue_comment.comment_html = ( - f"

{issue_comment.comment_stripped}

" - ) + issue_comment.comment_html = f"

{issue_comment.comment_stripped}

" updated_issue_comments.append(issue_comment) IssueComment.objects.bulk_update( @@ -103,9 +99,7 @@ def updated_issue_sort_order(): issue.sort_order = issue.sequence_id * random.randint(100, 500) updated_issues.append(issue) - Issue.objects.bulk_update( - updated_issues, ["sort_order"], batch_size=100 - ) + Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100) print("Success") except Exception as e: print(e) @@ -143,9 +137,7 @@ def update_project_cover_images(): project.cover_image = project_cover_images[random.randint(0, 19)] updated_projects.append(project) - Project.objects.bulk_update( - updated_projects, ["cover_image"], batch_size=100 - ) + Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100) print("Success") except Exception as e: print(e) @@ -194,9 +186,7 @@ def update_label_color(): def create_slack_integration(): try: - _ = Integration.objects.create( - provider="slack", network=2, title="Slack" - ) + _ = Integration.objects.create(provider="slack", network=2, title="Slack") print("Success") except Exception as e: print(e) @@ -222,16 +212,12 @@ def update_integration_verified(): def update_start_date(): try: - issues = Issue.objects.filter( - state__group__in=["started", "completed"] - ) + issues = Issue.objects.filter(state__group__in=["started", "completed"]) updated_issues = [] for issue in issues: issue.start_date = issue.created_at.date() updated_issues.append(issue) - Issue.objects.bulk_update( - updated_issues, ["start_date"], batch_size=500 - ) + Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500) print("Success") except Exception as e: print(e) diff --git a/apiserver/manage.py b/apiserver/manage.py index 744086783..972869462 100644 --- a/apiserver/manage.py +++ b/apiserver/manage.py @@ -3,9 +3,7 @@ import os import sys if __name__ == "__main__": - os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "plane.settings.production" - ) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/apiserver/package.json b/apiserver/package.json index f26382c89..4b44b3898 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.23.1" + "version": "0.24.0" } diff --git a/apiserver/plane/api/middleware/api_authentication.py b/apiserver/plane/api/middleware/api_authentication.py index 893df7f84..ddabb4132 100644 --- a/apiserver/plane/api/middleware/api_authentication.py +++ b/apiserver/plane/api/middleware/api_authentication.py @@ -25,10 +25,7 @@ class APIKeyAuthentication(authentication.BaseAuthentication): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q( - Q(expired_at__gt=timezone.now()) - | Q(expired_at__isnull=True) - ), + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), token=token, is_active=True, ) diff --git a/apiserver/plane/api/rate_limit.py b/apiserver/plane/api/rate_limit.py index 65cfd6b0c..a70ae4186 100644 --- a/apiserver/plane/api/rate_limit.py +++ b/apiserver/plane/api/rate_limit.py @@ -80,4 +80,4 @@ class ServiceTokenRateThrottle(SimpleRateThrottle): request.META["X-RateLimit-Remaining"] = max(0, available) request.META["X-RateLimit-Reset"] = reset_time - return allowed \ No newline at end of file + return allowed diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 72c5f8da9..b7780a90c 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -5,7 +5,6 @@ from .issue import ( IssueSerializer, LabelSerializer, IssueLinkSerializer, - IssueAttachmentSerializer, IssueCommentSerializer, IssueAttachmentSerializer, IssueActivitySerializer, @@ -14,9 +13,5 @@ from .issue import ( ) from .state import StateLiteSerializer, StateSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer -from .module import ( - ModuleSerializer, - ModuleIssueSerializer, - ModuleLiteSerializer, -) -from .inbox import InboxIssueSerializer +from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer +from .intake import IntakeIssueSerializer diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index 5653ba318..d51434554 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -102,8 +102,6 @@ class BaseSerializer(serializers.ModelSerializer): response[expand] = exp_serializer.data else: # You might need to handle this case differently - response[expand] = getattr( - instance, f"{expand}_id", None - ) + response[expand] = getattr(instance, f"{expand}_id", None) return response diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 90e3e1b42..f4f06c324 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -23,9 +23,7 @@ class CycleSerializer(BaseSerializer): and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed end date" - ) + raise serializers.ValidationError("Start date cannot exceed end date") return data class Meta: @@ -50,11 +48,7 @@ class CycleIssueSerializer(BaseSerializer): class Meta: model = CycleIssue fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "cycle", - ] + read_only_fields = ["workspace", "project", "cycle"] class CycleLiteSerializer(BaseSerializer): diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/intake.py similarity index 66% rename from apiserver/plane/api/serializers/inbox.py rename to apiserver/plane/api/serializers/intake.py index a0c79235d..69c85ed61 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/intake.py @@ -1,15 +1,16 @@ # Module improts from .base import BaseSerializer from .issue import IssueExpandSerializer -from plane.db.models import InboxIssue +from plane.db.models import IntakeIssue +from rest_framework import serializers -class InboxIssueSerializer(BaseSerializer): - +class IntakeIssueSerializer(BaseSerializer): issue_detail = IssueExpandSerializer(read_only=True, source="issue") + inbox = serializers.UUIDField(source="intake.id", read_only=True) class Meta: - model = InboxIssue + model = IntakeIssue fields = "__all__" read_only_fields = [ "id", diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index ab054ae51..72918b268 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -11,7 +11,7 @@ from plane.db.models import ( IssueType, IssueActivity, IssueAssignee, - IssueAttachment, + FileAsset, IssueComment, IssueLabel, IssueLink, @@ -31,6 +31,7 @@ from .user import UserLiteSerializer from django.core.exceptions import ValidationError from django.core.validators import URLValidator + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField( @@ -48,25 +49,13 @@ class IssueSerializer(BaseSerializer): required=False, ) type_id = serializers.PrimaryKeyRelatedField( - source="type", - queryset=IssueType.objects.all(), - required=False, - allow_null=True, + source="type", queryset=IssueType.objects.all(), required=False, allow_null=True ) class Meta: model = Issue - read_only_fields = [ - "id", - "workspace", - "project", - "updated_by", - "updated_at", - ] - exclude = [ - "description", - "description_stripped", - ] + read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] + exclude = ["description", "description_stripped"] def validate(self, data): if ( @@ -74,9 +63,7 @@ class IssueSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed target date" - ) + raise serializers.ValidationError("Start date cannot exceed target date") try: if data.get("description_html", None) is not None: @@ -98,16 +85,14 @@ class IssueSerializer(BaseSerializer): # Validate labels are from project if data.get("labels", []): data["labels"] = Label.objects.filter( - project_id=self.context.get("project_id"), - id__in=data["labels"], + project_id=self.context.get("project_id"), id__in=data["labels"] ).values_list("id", flat=True) # Check state is from the project only else raise validation error if ( data.get("state") and not State.objects.filter( - project_id=self.context.get("project_id"), - pk=data.get("state").id, + project_id=self.context.get("project_id"), pk=data.get("state").id ).exists() ): raise serializers.ValidationError( @@ -118,8 +103,7 @@ class IssueSerializer(BaseSerializer): if ( data.get("parent") and not Issue.objects.filter( - workspace_id=self.context.get("workspace_id"), - pk=data.get("parent").id, + workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id ).exists() ): raise serializers.ValidationError( @@ -146,9 +130,7 @@ class IssueSerializer(BaseSerializer): issue_type = issue_type issue = Issue.objects.create( - **validated_data, - project_id=project_id, - type=issue_type, + **validated_data, project_id=project_id, type=issue_type ) # Issue Audit Users @@ -263,13 +245,9 @@ class IssueSerializer(BaseSerializer): ] if "labels" in self.fields: if "labels" in self.expand: - data["labels"] = LabelSerializer( - instance.labels.all(), many=True - ).data + data["labels"] = LabelSerializer(instance.labels.all(), many=True).data else: - data["labels"] = [ - str(label.id) for label in instance.labels.all() - ] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data @@ -277,11 +255,7 @@ class IssueSerializer(BaseSerializer): class IssueLiteSerializer(BaseSerializer): class Meta: model = Issue - fields = [ - "id", - "sequence_id", - "project_id", - ] + fields = ["id", "sequence_id", "project_id"] read_only_fields = fields @@ -315,7 +289,7 @@ class IssueLinkSerializer(BaseSerializer): "created_at", "updated_at", ] - + def validate_url(self, value): # Check URL format validate_url = URLValidator() @@ -333,8 +307,7 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), - issue_id=validated_data.get("issue_id"), + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -344,8 +317,7 @@ class IssueLinkSerializer(BaseSerializer): def update(self, instance, validated_data): if ( IssueLink.objects.filter( - url=validated_data.get("url"), - issue_id=instance.issue_id, + url=validated_data.get("url"), issue_id=instance.issue_id ) .exclude(pk=instance.id) .exists() @@ -359,7 +331,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "id", @@ -386,10 +358,7 @@ class IssueCommentSerializer(BaseSerializer): "created_at", "updated_at", ] - exclude = [ - "comment_stripped", - "comment_json", - ] + exclude = ["comment_stripped", "comment_json"] def validate(self, data): try: @@ -406,38 +375,27 @@ class IssueCommentSerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer): class Meta: model = IssueActivity - exclude = [ - "created_by", - "updated_by", - ] + exclude = ["created_by", "updated_by"] class CycleIssueSerializer(BaseSerializer): cycle = CycleSerializer(read_only=True) class Meta: - fields = [ - "cycle", - ] + fields = ["cycle"] class ModuleIssueSerializer(BaseSerializer): module = ModuleSerializer(read_only=True) class Meta: - fields = [ - "module", - ] + fields = ["module"] class LabelLiteSerializer(BaseSerializer): class Meta: model = Label - fields = [ - "id", - "name", - "color", - ] + fields = ["id", "name", "color"] class IssueExpandSerializer(BaseSerializer): diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index c1a0c577d..ace4e15c8 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -53,14 +53,11 @@ class ModuleSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed target date" - ) + raise serializers.ValidationError("Start date cannot exceed target date") if data.get("members", []): data["members"] = ProjectMember.objects.filter( - project_id=self.context.get("project_id"), - member_id__in=data["members"], + project_id=self.context.get("project_id"), member_id__in=data["members"] ).values_list("member_id", flat=True) return data @@ -74,9 +71,7 @@ class ModuleSerializer(BaseSerializer): module_name = validated_data.get("name") if module_name: # Lookup for the module name in the module table for that project - if Module.objects.filter( - name=module_name, project_id=project_id - ).exists(): + if Module.objects.filter(name=module_name, project_id=project_id).exists(): raise serializers.ValidationError( {"error": "Module with this name already exists"} ) @@ -107,9 +102,7 @@ class ModuleSerializer(BaseSerializer): if module_name: # Lookup for the module name in the module table for that project if ( - Module.objects.filter( - name=module_name, project=instance.project - ) + Module.objects.filter(name=module_name, project=instance.project) .exclude(id=instance.id) .exists() ): @@ -172,8 +165,7 @@ class ModuleLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if ModuleLink.objects.filter( - url=validated_data.get("url"), - module_id=validated_data.get("module_id"), + url=validated_data.get("url"), module_id=validated_data.get("module_id") ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index d1fea2023..744084ab1 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -2,11 +2,7 @@ from rest_framework import serializers # Module imports -from plane.db.models import ( - Project, - ProjectIdentifier, - WorkspaceMember, -) +from plane.db.models import Project, ProjectIdentifier, WorkspaceMember from .base import BaseSerializer @@ -19,6 +15,8 @@ class ProjectSerializer(BaseSerializer): sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) is_deployed = serializers.BooleanField(read_only=True) + cover_image_url = serializers.CharField(read_only=True) + inbox_view = serializers.BooleanField(read_only=True, source="intake_view") class Meta: model = Project @@ -32,6 +30,7 @@ class ProjectSerializer(BaseSerializer): "created_by", "updated_by", "deleted_at", + "cover_image_url", ] def validate(self, data): @@ -64,16 +63,12 @@ class ProjectSerializer(BaseSerializer): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError( - detail="Project Identifier is required" - ) + raise serializers.ValidationError(detail="Project Identifier is required") if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError( - detail="Project Identifier is taken" - ) + raise serializers.ValidationError(detail="Project Identifier is taken") project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] @@ -87,6 +82,8 @@ class ProjectSerializer(BaseSerializer): class ProjectLiteSerializer(BaseSerializer): + cover_image_url = serializers.CharField(read_only=True) + class Meta: model = Project fields = [ @@ -97,5 +94,6 @@ class ProjectLiteSerializer(BaseSerializer): "icon_prop", "emoji", "description", + "cover_image_url", ] read_only_fields = fields diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index f4ffcba85..85b4c41ed 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -7,9 +7,9 @@ class StateSerializer(BaseSerializer): def validate(self, data): # If the default is being provided then make all other states default False if data.get("default", False): - State.objects.filter( - project_id=self.context.get("project_id") - ).update(default=False) + State.objects.filter(project_id=self.context.get("project_id")).update( + default=False + ) return data class Meta: @@ -30,10 +30,5 @@ class StateSerializer(BaseSerializer): class StateLiteSerializer(BaseSerializer): class Meta: model = State - fields = [ - "id", - "name", - "color", - "group", - ] + fields = ["id", "name", "color", "group"] read_only_fields = fields diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index e853b90c2..b266d7d54 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer): "last_name", "email", "avatar", + "avatar_url", "display_name", "email", ] diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index a47de3d31..84453b8e0 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -8,9 +8,5 @@ class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace - fields = [ - "name", - "slug", - "id", - ] + fields = ["name", "slug", "id"] read_only_fields = fields diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index efa84bce0..d9b55e20e 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -3,7 +3,7 @@ from .state import urlpatterns as state_patterns from .issue import urlpatterns as issue_patterns from .cycle import urlpatterns as cycle_patterns from .module import urlpatterns as module_patterns -from .inbox import urlpatterns as inbox_patterns +from .intake import urlpatterns as intake_patterns from .member import urlpatterns as member_patterns urlpatterns = [ @@ -12,6 +12,6 @@ urlpatterns = [ *issue_patterns, *cycle_patterns, *module_patterns, - *inbox_patterns, + *intake_patterns, *member_patterns, ] diff --git a/apiserver/plane/api/urls/inbox.py b/apiserver/plane/api/urls/inbox.py deleted file mode 100644 index 95eb68f3f..000000000 --- a/apiserver/plane/api/urls/inbox.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path - -from plane.api.views import InboxIssueAPIEndpoint - - -urlpatterns = [ - path( - "workspaces//projects//inbox-issues/", - InboxIssueAPIEndpoint.as_view(), - name="inbox-issue", - ), - path( - "workspaces//projects//inbox-issues//", - InboxIssueAPIEndpoint.as_view(), - name="inbox-issue", - ), -] diff --git a/apiserver/plane/api/urls/intake.py b/apiserver/plane/api/urls/intake.py new file mode 100644 index 000000000..a47d010ee --- /dev/null +++ b/apiserver/plane/api/urls/intake.py @@ -0,0 +1,27 @@ +from django.urls import path + +from plane.api.views import IntakeIssueAPIEndpoint + + +urlpatterns = [ + path( + "workspaces//projects//inbox-issues/", + IntakeIssueAPIEndpoint.as_view(), + name="inbox-issue", + ), + path( + "workspaces//projects//inbox-issues//", + IntakeIssueAPIEndpoint.as_view(), + name="inbox-issue", + ), + path( + "workspaces//projects//intake-issues/", + IntakeIssueAPIEndpoint.as_view(), + name="intake-issue", + ), + path( + "workspaces//projects//intake-issues//", + IntakeIssueAPIEndpoint.as_view(), + name="intake-issue", + ), +] diff --git a/apiserver/plane/api/urls/member.py b/apiserver/plane/api/urls/member.py index 5fe1785a7..1ec9cddb3 100644 --- a/apiserver/plane/api/urls/member.py +++ b/apiserver/plane/api/urls/member.py @@ -1,13 +1,11 @@ from django.urls import path -from plane.api.views import ( - ProjectMemberAPIEndpoint, -) +from plane.api.views import ProjectMemberAPIEndpoint urlpatterns = [ path( "workspaces//projects//members/", ProjectMemberAPIEndpoint.as_view(), name="users", - ), + ) ] diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index 5efb85bb0..d35c2cdd5 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -1,15 +1,10 @@ from django.urls import path -from plane.api.views import ( - ProjectAPIEndpoint, - ProjectArchiveUnarchiveAPIEndpoint, -) +from plane.api.views import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint urlpatterns = [ path( - "workspaces//projects/", - ProjectAPIEndpoint.as_view(), - name="project", + "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project" ), path( "workspaces//projects//", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index bbec428c0..2299f7ec5 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -27,5 +27,4 @@ from .module import ( from .member import ProjectMemberAPIEndpoint -from .inbox import InboxIssueAPIEndpoint - +from .intake import IntakeIssueAPIEndpoint diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index a3241eaf3..c79c2f853 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -37,13 +37,9 @@ class TimezoneMixin: class BaseAPIView(TimezoneMixin, APIView, BasePaginator): - authentication_classes = [ - APIKeyAuthentication, - ] + authentication_classes = [APIKeyAuthentication] - permission_classes = [ - IsAuthenticated, - ] + permission_classes = [IsAuthenticated] def filter_queryset(self, queryset): for backend in list(self.filter_backends): @@ -56,8 +52,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if api_key: service_token = APIToken.objects.filter( - token=api_key, - is_service=True, + token=api_key, is_service=True ).first() if service_token: @@ -123,9 +118,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): def finalize_response(self, request, response, *args, **kwargs): # Call super to get the default response - response = super().finalize_response( - request, response, *args, **kwargs - ) + response = super().finalize_response(request, response, *args, **kwargs) # Add custom headers if they exist in the request META ratelimit_remaining = request.META.get("X-RateLimit-Remaining") @@ -154,17 +147,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def fields(self): fields = [ - field - for field in self.request.GET.get("fields", "").split(",") - if field + field for field in self.request.GET.get("fields", "").split(",") if field ] return fields if fields else None @property def expand(self): expand = [ - expand - for expand in self.request.GET.get("expand", "").split(",") - if expand + expand for expand in self.request.GET.get("expand", "").split(",") if expand ] - return expand if expand else None \ No newline at end of file + return expand if expand else None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 381446632..3665e3b0f 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -13,18 +13,19 @@ from django.db.models import ( Q, Sum, FloatField, + Case, + When, + Value, ) -from django.db.models.functions import Cast +from django.db.models.functions import Cast, Concat +from django.db import models # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from plane.api.serializers import ( - CycleIssueSerializer, - CycleSerializer, -) +from plane.api.serializers import CycleIssueSerializer, CycleSerializer from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( @@ -32,7 +33,7 @@ from plane.db.models import ( CycleIssue, Issue, Project, - IssueAttachment, + FileAsset, IssueLink, ProjectMember, UserFavorite, @@ -53,9 +54,7 @@ class CycleAPIEndpoint(BaseAPIView): serializer_class = CycleSerializer model = Cycle webhook_event = "cycle" - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( @@ -74,6 +73,7 @@ class CycleAPIEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -84,6 +84,7 @@ class CycleAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -94,6 +95,7 @@ class CycleAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -104,6 +106,7 @@ class CycleAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -114,6 +117,7 @@ class CycleAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -124,6 +128,7 @@ class CycleAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -133,26 +138,18 @@ class CycleAPIEndpoint(BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: - queryset = ( - self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) - ) + queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) data = CycleSerializer( - queryset, - fields=self.fields, - expand=self.expand, + queryset, fields=self.fields, expand=self.expand ).data - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") # Current Cycle if cycle_view == "current": queryset = queryset.filter( - start_date__lte=timezone.now(), - end_date__gte=timezone.now(), + start_date__lte=timezone.now(), end_date__gte=timezone.now() ) data = CycleSerializer( queryset, many=True, fields=self.fields, expand=self.expand @@ -166,10 +163,7 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -180,54 +174,38 @@ class CycleAPIEndpoint(BaseAPIView): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) # Draft Cycles if cycle_view == "draft": - queryset = queryset.filter( - end_date=None, - start_date=None, - ) + queryset = queryset.filter(end_date=None, start_date=None) return self.paginate( request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) # Incomplete Cycles if cycle_view == "incomplete": queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) - | Q(end_date__isnull=True), + Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) ) return self.paginate( request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) return self.paginate( request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -264,10 +242,7 @@ class CycleAPIEndpoint(BaseAPIView): }, status=status.HTTP_409_CONFLICT, ) - serializer.save( - project_id=project_id, - owned_by=request.user, - ) + serializer.save(project_id=project_id, owned_by=request.user) # Send the model activity model_activity.delay( model_name="cycle", @@ -278,12 +253,8 @@ class CycleAPIEndpoint(BaseAPIView): slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( { @@ -293,9 +264,7 @@ class CycleAPIEndpoint(BaseAPIView): ) def patch(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) current_instance = json.dumps( CycleSerializer(cycle).data, cls=DjangoJSONEncoder @@ -309,16 +278,11 @@ class CycleAPIEndpoint(BaseAPIView): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order request_data = { - "sort_order": request_data.get( - "sort_order", cycle.sort_order - ) + "sort_order": request_data.get("sort_order", cycle.sort_order) } else: return Response( @@ -365,9 +329,7 @@ class CycleAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if cycle.owned_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, @@ -383,9 +345,9 @@ class CycleAPIEndpoint(BaseAPIView): ) cycle_issues = list( - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk") - ).values_list("issue", flat=True) + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) ) issue_activity.delay( @@ -405,23 +367,15 @@ class CycleAPIEndpoint(BaseAPIView): ) # Delete the cycle cycle.delete() - # Delete the cycle issues - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk"), - ).delete() # Delete the user favorite cycle UserFavorite.objects.filter( - entity_type="cycle", - entity_identifier=pk, - project_id=project_id, + entity_type="cycle", entity_identifier=pk, project_id=project_id ).delete() return Response(status=status.HTTP_204_NO_CONTENT) class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( @@ -441,6 +395,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -451,6 +406,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -461,6 +417,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -471,6 +428,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -481,6 +439,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -491,12 +450,11 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", @@ -504,6 +462,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -514,6 +473,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -526,10 +486,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): request=request, queryset=(self.get_queryset()), on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, + cycles, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -537,7 +494,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): cycle = Cycle.objects.get( pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, @@ -572,16 +529,12 @@ class CycleIssueAPIEndpoint(BaseAPIView): model = CycleIssue webhook_event = "cycle_issue" bulk = True - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( CycleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("issue_id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -619,11 +572,11 @@ class CycleIssueAPIEndpoint(BaseAPIView): # List order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True + ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -645,8 +598,9 @@ class CycleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -658,10 +612,7 @@ class CycleIssueAPIEndpoint(BaseAPIView): request=request, queryset=(issues), on_results=lambda issues: CycleSerializer( - issues, - many=True, - fields=self.fields, - expand=self.expand, + issues, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -670,8 +621,7 @@ class CycleIssueAPIEndpoint(BaseAPIView): if not issues: return Response( - {"error": "Issues are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) cycle = Cycle.objects.get( @@ -680,9 +630,7 @@ class CycleIssueAPIEndpoint(BaseAPIView): # Get all CycleIssues already created cycle_issues = list( - CycleIssue.objects.filter( - ~Q(cycle_id=cycle_id), issue_id__in=issues - ) + CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) ) existing_issues = [ @@ -727,9 +675,7 @@ class CycleIssueAPIEndpoint(BaseAPIView): ) # Update the cycle issues - CycleIssue.objects.bulk_update( - updated_records, ["cycle_id"], batch_size=100 - ) + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( @@ -788,9 +734,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): """ - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def post(self, request, slug, project_id, cycle_id): new_cycle_id = request.data.get("new_cycle_id", False) @@ -815,6 +759,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -825,6 +770,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -835,6 +781,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -845,6 +792,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -855,6 +803,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -865,6 +814,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -881,18 +831,37 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): assignee_estimate_data = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), ) ) + .values("display_name", "assignee_id", "avatar", "avatar_url") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -920,11 +889,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): { "display_name": item["display_name"], "assignee_id": ( - str(item["assignee_id"]) - if item["assignee_id"] - else None + str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -935,6 +903,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): label_distribution_data = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -943,9 +912,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -982,9 +949,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): { "label_name": item["label_name"], "color": item["color"], - "label_id": ( - str(item["label_id"]) if item["label_id"] else None - ), + "label_id": (str(item["label_id"]) if item["label_id"] else None), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -996,21 +961,37 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -1041,7 +1022,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1053,6 +1035,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -1062,12 +1045,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -1097,9 +1076,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): { "label_name": item["label_name"], "color": item["color"], - "label_id": ( - str(item["label_id"]) if item["label_id"] else None - ), + "label_id": (str(item["label_id"]) if item["label_id"] else None), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1144,10 +1121,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): } current_cycle.save(update_fields=["progress_snapshot"]) - if ( - new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() - ): + if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): return Response( { "error": "The cycle where the issues are transferred is already completed" diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/intake.py similarity index 64% rename from apiserver/plane/api/views/inbox.py rename to apiserver/plane/api/views/intake.py index f7e18dd76..c2d0733ba 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/intake.py @@ -14,60 +14,47 @@ from rest_framework import status from rest_framework.response import Response # Module imports -from plane.api.serializers import InboxIssueSerializer, IssueSerializer +from plane.api.serializers import IntakeIssueSerializer, IssueSerializer from plane.app.permissions import ProjectLitePermission from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Inbox, - InboxIssue, - Issue, - Project, - ProjectMember, - State, -) +from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State from .base import BaseAPIView -class InboxIssueAPIEndpoint(BaseAPIView): +class IntakeIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to inbox issues. + `update` and `destroy` actions related to intake issues. """ - permission_classes = [ - ProjectLitePermission, - ] + permission_classes = [ProjectLitePermission] - serializer_class = InboxIssueSerializer - model = InboxIssue + serializer_class = IntakeIssueSerializer + model = IntakeIssue - filterset_fields = [ - "status", - ] + filterset_fields = ["status"] def get_queryset(self): - inbox = Inbox.objects.filter( + intake = Intake.objects.filter( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ).first() project = Project.objects.get( - workspace__slug=self.kwargs.get("slug"), - pk=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") ) - if inbox is None and not project.inbox_view: - return InboxIssue.objects.none() + if intake is None and not project.intake_view: + return IntakeIssue.objects.none() return ( - InboxIssue.objects.filter( - Q(snoozed_till__gte=timezone.now()) - | Q(snoozed_till__isnull=True), + IntakeIssue.objects.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), - inbox_id=inbox.id, + intake_id=intake.id, ) .select_related("issue", "workspace", "project") .order_by(self.kwargs.get("order_by", "-created_at")) @@ -75,49 +62,37 @@ class InboxIssueAPIEndpoint(BaseAPIView): def get(self, request, slug, project_id, issue_id=None): if issue_id: - inbox_issue_queryset = self.get_queryset().get(issue_id=issue_id) - inbox_issue_data = InboxIssueSerializer( - inbox_issue_queryset, - fields=self.fields, - expand=self.expand, + intake_issue_queryset = self.get_queryset().get(issue_id=issue_id) + intake_issue_data = IntakeIssueSerializer( + intake_issue_queryset, fields=self.fields, expand=self.expand ).data - return Response( - inbox_issue_data, - status=status.HTTP_200_OK, - ) + return Response(intake_issue_data, status=status.HTTP_200_OK) issue_queryset = self.get_queryset() return self.paginate( request=request, queryset=(issue_queryset), - on_results=lambda inbox_issues: InboxIssueSerializer( - inbox_issues, - many=True, - fields=self.fields, - expand=self.expand, + on_results=lambda intake_issues: IntakeIssueSerializer( + intake_issues, many=True, fields=self.fields, expand=self.expand ).data, ) def post(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) - inbox = Inbox.objects.filter( + intake = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - project = Project.objects.get( - workspace__slug=slug, - pk=project_id, - ) + project = Project.objects.get(workspace__slug=slug, pk=project_id) - # Inbox view - if inbox is None and not project.inbox_view: + # Intake view + if intake is None and not project.intake_view: return Response( { - "error": "Inbox is not enabled for this project enable it through the project's api" + "error": "Intake is not enabled for this project enable it through the project's api" }, status=status.HTTP_400_BAD_REQUEST, ) @@ -131,15 +106,14 @@ class InboxIssueAPIEndpoint(BaseAPIView): "none", ]: return Response( - {"error": "Invalid priority"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) # Create or get state state, _ = State.objects.get_or_create( name="Triage", group="triage", - description="Default state for managing all Inbox Issues", + description="Default state for managing all Intake Issues", project_id=project_id, color="#ff7700", is_triage=True, @@ -157,12 +131,12 @@ class InboxIssueAPIEndpoint(BaseAPIView): state=state, ) - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox.id, + # create an intake issue + intake_issue = IntakeIssue.objects.create( + intake_id=intake.id, project_id=project_id, issue=issue, - source=request.data.get("source", "in-app"), + source=request.data.get("source", "IN-APP"), ) # Create an Issue Activity issue_activity.delay( @@ -173,32 +147,34 @@ class InboxIssueAPIEndpoint(BaseAPIView): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), - inbox=str(inbox_issue.id), + intake=str(intake_issue.id), ) - serializer = InboxIssueSerializer(inbox_issue) + serializer = IntakeIssueSerializer(intake_issue) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, issue_id): - inbox = Inbox.objects.filter( + intake = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - # Inbox view - if inbox is None: + project = Project.objects.get(workspace__slug=slug, pk=project_id) + + # Intake view + if intake is None and not project.intake_view: return Response( { - "error": "Inbox is not enabled for this project enable it through the project's api" + "error": "Intake is not enabled for this project enable it through the project's api" }, status=status.HTTP_400_BAD_REQUEST, ) - # Get the inbox issue - inbox_issue = InboxIssue.objects.get( + # Get the intake issue + intake_issue = IntakeIssue.objects.get( issue_id=issue_id, workspace__slug=slug, project_id=project_id, - inbox_id=inbox.id, + intake_id=intake.id, ) # Get the project member @@ -210,11 +186,11 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Only project members admins and created_by users can access this endpoint - if project_member.role <= 5 and str(inbox_issue.created_by_id) != str( + if project_member.role <= 5 and str(intake_issue.created_by_id) != str( request.user.id ): return Response( - {"error": "You cannot edit inbox issues"}, + {"error": "You cannot edit intake issues"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -227,7 +203,10 @@ class InboxIssueAPIEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -235,15 +214,15 @@ class InboxIssueAPIEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), - ).get( - pk=issue_id, - workspace__slug=slug, - project_id=project_id, - ) + ).get(pk=issue_id, workspace__slug=slug, project_id=project_id) # Only allow guests to edit name and description if project_member.role <= 5: issue_data = { @@ -251,14 +230,10 @@ class InboxIssueAPIEndpoint(BaseAPIView): "description_html": issue_data.get( "description_html", issue.description_html ), - "description": issue_data.get( - "description", issue.description - ), + "description": issue_data.get("description", issue.description), } - issue_serializer = IssueSerializer( - issue, data=issue_data, partial=True - ) + issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) if issue_serializer.is_valid(): current_instance = issue @@ -276,7 +251,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), - inbox=(inbox_issue.id), + intake=(intake_issue.id), ) issue_serializer.save() else: @@ -284,13 +259,13 @@ class InboxIssueAPIEndpoint(BaseAPIView): issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - # Only project admins and members can edit inbox issue attributes + # Only project admins and members can edit intake issue attributes if project_member.role > 15: - serializer = InboxIssueSerializer( - inbox_issue, data=request.data, partial=True + serializer = IntakeIssueSerializer( + intake_issue, data=request.data, partial=True ) current_instance = json.dumps( - InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder ) if serializer.is_valid(): @@ -298,14 +273,10 @@ class InboxIssueAPIEndpoint(BaseAPIView): # Update the issue state if the issue is rejected or marked as duplicate if serializer.data["status"] in [-1, 2]: issue = Issue.objects.get( - pk=issue_id, - workspace__slug=slug, - project_id=project_id, + pk=issue_id, workspace__slug=slug, project_id=project_id ) state = State.objects.filter( - group="cancelled", - workspace__slug=slug, - project_id=project_id, + group="cancelled", workspace__slug=slug, project_id=project_id ).first() if state is not None: issue.state = state @@ -314,18 +285,14 @@ class InboxIssueAPIEndpoint(BaseAPIView): # Update the issue state if it is accepted if serializer.data["status"] in [1]: issue = Issue.objects.get( - pk=issue_id, - workspace__slug=slug, - project_id=project_id, + pk=issue_id, workspace__slug=slug, project_id=project_id ) # Update the issue state only if it is in triage state if issue.state.is_triage: # Move to default state state = State.objects.filter( - workspace__slug=slug, - project_id=project_id, - default=True, + workspace__slug=slug, project_id=project_id, default=True ).first() if state is not None: issue.state = state @@ -333,10 +300,8 @@ class InboxIssueAPIEndpoint(BaseAPIView): # create a activity for status change issue_activity.delay( - type="inbox.activity.created", - requested_data=json.dumps( - request.data, cls=DjangoJSONEncoder - ), + type="intake.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), @@ -344,48 +309,42 @@ class InboxIssueAPIEndpoint(BaseAPIView): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), - inbox=str(inbox_issue.id), + intake=str(intake_issue.id), ) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - InboxIssueSerializer(inbox_issue).data, - status=status.HTTP_200_OK, + IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK ) def delete(self, request, slug, project_id, issue_id): - inbox = Inbox.objects.filter( + intake = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - project = Project.objects.get( - workspace__slug=slug, - pk=project_id, - ) + project = Project.objects.get(workspace__slug=slug, pk=project_id) - # Inbox view - if inbox is None and not project.inbox_view: + # Intake view + if intake is None and not project.intake_view: return Response( { - "error": "Inbox is not enabled for this project enable it through the project's api" + "error": "Intake is not enabled for this project enable it through the project's api" }, status=status.HTTP_400_BAD_REQUEST, ) - # Get the inbox issue - inbox_issue = InboxIssue.objects.get( + # Get the intake issue + intake_issue = IntakeIssue.objects.get( issue_id=issue_id, workspace__slug=slug, project_id=project_id, - inbox_id=inbox.id, + intake_id=intake.id, ) # Check the issue status - if inbox_issue.status in [-2, -1, 0, 2]: + if intake_issue.status in [-2, -1, 0, 2]: # Delete the issue also issue = Issue.objects.filter( workspace__slug=slug, project_id=project_id, pk=issue_id @@ -405,5 +364,5 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) issue.delete() - inbox_issue.delete() + intake_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1cd8ed1b4..df7b9aec2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -16,6 +16,7 @@ from django.db.models import ( Q, Value, When, + Subquery, ) from django.utils import timezone @@ -42,12 +43,13 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueComment, IssueLink, Label, Project, ProjectMember, + CycleIssue, ) from .base import BaseAPIView @@ -71,9 +73,7 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -89,14 +89,10 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView): .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - def get( - self, request, slug, project__identifier=None, issue__identifier=None - ): + def get(self, request, slug, project__identifier=None, issue__identifier=None): if issue__identifier and project__identifier: issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -106,11 +102,7 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView): sequence_id=issue__identifier, ) return Response( - IssueSerializer( - issue, - fields=self.fields, - expand=self.expand, - ).data, + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) @@ -124,17 +116,13 @@ class IssueAPIEndpoint(BaseAPIView): model = Issue webhook_event = "issue" - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] serializer_class = IssueSerializer def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -162,47 +150,37 @@ class IssueAPIEndpoint(BaseAPIView): project_id=project_id, ) return Response( - IssueSerializer( - issue, - fields=self.fields, - expand=self.expand, - ).data, + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) if pk: issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ).get(workspace__slug=slug, project_id=project_id, pk=pk) return Response( - IssueSerializer( - issue, - fields=self.fields, - expand=self.expand, - ).data, + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, ) # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( self.get_queryset() - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -210,8 +188,9 @@ class IssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -222,9 +201,7 @@ class IssueAPIEndpoint(BaseAPIView): # 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] + priority_order if order_by_param == "priority" else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -272,9 +249,7 @@ class IssueAPIEndpoint(BaseAPIView): else order_by_param ) ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" + "-max_values" if order_by_param.startswith("-") else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -283,10 +258,7 @@ class IssueAPIEndpoint(BaseAPIView): request=request, queryset=(issue_queryset), on_results=lambda issues: IssueSerializer( - issues, - many=True, - fields=self.fields, - expand=self.expand, + issues, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -330,22 +302,16 @@ class IssueAPIEndpoint(BaseAPIView): serializer.save() # Refetch the issue issue = Issue.objects.filter( - workspace__slug=slug, - project_id=project_id, - pk=serializer.data["id"], + workspace__slug=slug, project_id=project_id, pk=serializer.data["id"] ).first() issue.created_at = request.data.get("created_at", timezone.now()) - issue.created_by_id = request.data.get( - "created_by", request.user.id - ) + issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) # Track the issue issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), @@ -382,9 +348,7 @@ class IssueAPIEndpoint(BaseAPIView): # Get the requested data, encode it as django object and pass it # to serializer to validation - requested_data = json.dumps( - self.request.data, cls=DjangoJSONEncoder - ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueSerializer( issue, data=request.data, @@ -442,9 +406,7 @@ class IssueAPIEndpoint(BaseAPIView): # If any of the created_at or created_by is present, update # the issue with the provided data, else return with the # default states given. - issue.created_at = request.data.get( - "created_at", timezone.now() - ) + issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get( "created_by", request.user.id ) @@ -461,12 +423,8 @@ class IssueAPIEndpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), ) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( {"error": "external_id and external_source are required"}, @@ -474,9 +432,7 @@ class IssueAPIEndpoint(BaseAPIView): ) def patch(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) project = Project.objects.get(pk=project_id) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder @@ -485,10 +441,7 @@ class IssueAPIEndpoint(BaseAPIView): serializer = IssueSerializer( issue, data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - }, + context={"project_id": project_id, "workspace_id": project.workspace_id}, partial=True, ) if serializer.is_valid(): @@ -526,9 +479,7 @@ class IssueAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if issue.created_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, @@ -567,9 +518,7 @@ class LabelAPIEndpoint(BaseAPIView): serializer_class = LabelSerializer model = Label - permission_classes = [ - ProjectMemberPermission, - ] + permission_classes = [ProjectMemberPermission] def get_queryset(self): return ( @@ -616,12 +565,8 @@ class LabelAPIEndpoint(BaseAPIView): ) serializer.save(project_id=project_id) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: label = Label.objects.filter( workspace__slug=slug, @@ -642,18 +587,11 @@ class LabelAPIEndpoint(BaseAPIView): request=request, queryset=(self.get_queryset()), on_results=lambda labels: LabelSerializer( - labels, - many=True, - fields=self.fields, - expand=self.expand, + labels, many=True, fields=self.fields, expand=self.expand ).data, ) label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer( - label, - fields=self.fields, - expand=self.expand, - ) + serializer = LabelSerializer(label, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): @@ -696,9 +634,7 @@ class IssueLinkAPIEndpoint(BaseAPIView): """ - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] model = IssueLink serializer_class = IssueLinkSerializer @@ -721,46 +657,32 @@ class IssueLinkAPIEndpoint(BaseAPIView): if pk is None: issue_links = self.get_queryset() serializer = IssueLinkSerializer( - issue_links, - fields=self.fields, - expand=self.expand, + issue_links, fields=self.fields, expand=self.expand ) return self.paginate( request=request, queryset=(self.get_queryset()), on_results=lambda issue_links: IssueLinkSerializer( - issue_links, - many=True, - fields=self.fields, - expand=self.expand, + issue_links, many=True, fields=self.fields, expand=self.expand ).data, ) issue_link = self.get_queryset().get(pk=pk) serializer = IssueLinkSerializer( - issue_link, - fields=self.fields, - expand=self.expand, + issue_link, fields=self.fields, expand=self.expand ) return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - ) + serializer.save(project_id=project_id, issue_id=issue_id) link = IssueLink.objects.get(pk=serializer.data["id"]) - link.created_by_id = request.data.get( - "created_by", request.user.id - ) + link.created_by_id = request.data.get("created_by", request.user.id) link.save(update_fields=["created_by"]) issue_activity.delay( type="link.activity.created", - requested_data=json.dumps( - serializer.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), actor_id=str(link.created_by_id), @@ -772,19 +694,13 @@ class IssueLinkAPIEndpoint(BaseAPIView): def patch(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - serializer = IssueLinkSerializer( - issue_link, data=request.data, partial=True + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -801,14 +717,10 @@ class IssueLinkAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) issue_activity.delay( type="link.activity.deleted", @@ -833,15 +745,11 @@ class IssueCommentAPIEndpoint(BaseAPIView): serializer_class = IssueCommentSerializer model = IssueComment webhook_event = "issue_comment" - permission_classes = [ - ProjectLitePermission, - ] + permission_classes = [ProjectLitePermission] def get_queryset(self): return ( - IssueComment.objects.filter( - workspace__slug=self.kwargs.get("slug") - ) + IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) .filter( @@ -868,19 +776,14 @@ class IssueCommentAPIEndpoint(BaseAPIView): if pk: issue_comment = self.get_queryset().get(pk=pk) serializer = IssueCommentSerializer( - issue_comment, - fields=self.fields, - expand=self.expand, + issue_comment, fields=self.fields, expand=self.expand ) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, queryset=(self.get_queryset()), on_results=lambda issue_comment: IssueCommentSerializer( - issue_comment, - many=True, - fields=self.fields, - expand=self.expand, + issue_comment, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -913,17 +816,11 @@ class IssueCommentAPIEndpoint(BaseAPIView): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, - issue_id=issue_id, - actor=request.user, - ) - issue_comment = IssueComment.objects.get( - pk=serializer.data.get("id") + project_id=project_id, issue_id=issue_id, actor=request.user ) + issue_comment = IssueComment.objects.get(pk=serializer.data.get("id")) # Update the created_at and the created_by and save the comment - issue_comment.created_at = request.data.get( - "created_at", timezone.now() - ) + issue_comment.created_at = request.data.get("created_at", timezone.now()) issue_comment.created_by_id = request.data.get( "created_by", request.user.id ) @@ -931,9 +828,7 @@ class IssueCommentAPIEndpoint(BaseAPIView): issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps( - serializer.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), actor_id=str(issue_comment.created_by_id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -945,24 +840,17 @@ class IssueCommentAPIEndpoint(BaseAPIView): def patch(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) # Validation check if the issue already exists if ( request.data.get("external_id") - and ( - issue_comment.external_id - != str(request.data.get("external_id")) - ) + and (issue_comment.external_id != str(request.data.get("external_id"))) and IssueComment.objects.filter( project_id=project_id, workspace__slug=slug, @@ -999,14 +887,10 @@ class IssueCommentAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) issue_comment.delete() issue_activity.delay( @@ -1022,9 +906,7 @@ class IssueCommentAPIEndpoint(BaseAPIView): class IssueActivityAPIEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get(self, request, slug, project_id, issue_id, pk=None): issue_activities = ( @@ -1049,20 +931,15 @@ class IssueActivityAPIEndpoint(BaseAPIView): request=request, queryset=(issue_activities), on_results=lambda issue_activity: IssueActivitySerializer( - issue_activity, - many=True, - fields=self.fields, - expand=self.expand, + issue_activity, many=True, fields=self.fields, expand=self.expand ).data, ) class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer - permission_classes = [ - ProjectEntityPermission, - ] - model = IssueAttachment + permission_classes = [ProjectEntityPermission] + model = FileAsset parser_classes = (MultiPartParser, FormParser) def post(self, request, slug, project_id, issue_id): @@ -1070,7 +947,7 @@ class IssueAttachmentEndpoint(BaseAPIView): if ( request.data.get("external_id") and request.data.get("external_source") - and IssueAttachment.objects.filter( + and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, issue_id=issue_id, @@ -1078,7 +955,7 @@ class IssueAttachmentEndpoint(BaseAPIView): external_id=request.data.get("external_id"), ).exists() ): - issue_attachment = IssueAttachment.objects.filter( + issue_attachment = FileAsset.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), @@ -1100,10 +977,7 @@ class IssueAttachmentEndpoint(BaseAPIView): actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - serializer.data, - cls=DjangoJSONEncoder, - ), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -1112,7 +986,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -1130,7 +1004,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) diff --git a/apiserver/plane/api/views/member.py b/apiserver/plane/api/views/member.py index d6e5fed0c..954ee030b 100644 --- a/apiserver/plane/api/views/member.py +++ b/apiserver/plane/api/views/member.py @@ -13,24 +13,14 @@ from rest_framework import status # Module imports from .base import BaseAPIView from plane.api.serializers import UserLiteSerializer -from plane.db.models import ( - User, - Workspace, - Project, - WorkspaceMember, - ProjectMember, -) +from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember -from plane.app.permissions import ( - ProjectMemberPermission, -) +from plane.app.permissions import ProjectMemberPermission # API endpoint to get and insert users inside the workspace class ProjectMemberAPIEndpoint(BaseAPIView): - permission_classes = [ - ProjectMemberPermission, - ] + permission_classes = [ProjectMemberPermission] # Get all the users that are present inside the workspace def get(self, request, slug, project_id): @@ -48,10 +38,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView): # Get all the users that are present inside the workspace users = UserLiteSerializer( - User.objects.filter( - id__in=project_members, - ), - many=True, + User.objects.filter(id__in=project_members), many=True ).data return Response(users, status=status.HTTP_200_OK) @@ -78,8 +65,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView): validate_email(email) except ValidationError: return Response( - {"error": "Invalid email provided"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST ) workspace = Workspace.objects.filter(slug=slug).first() @@ -108,9 +94,7 @@ class ProjectMemberAPIEndpoint(BaseAPIView): ).first() if project_member: return Response( - { - "error": "User is already part of the workspace and project" - }, + {"error": "User is already part of the workspace and project"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -131,18 +115,14 @@ class ProjectMemberAPIEndpoint(BaseAPIView): # Create a workspace member for the user if not already a member if not workspace_member: workspace_member = WorkspaceMember.objects.create( - workspace=workspace, - member=user, - role=request.data.get("role", 5), + workspace=workspace, member=user, role=request.data.get("role", 5) ) workspace_member.save() # Create a project member for the user if not already a member if not project_member: project_member = ProjectMember.objects.create( - project=project, - member=user, - role=request.data.get("role", 5), + project=project, member=user, role=request.data.get("role", 5) ) project_member.save() @@ -150,4 +130,3 @@ class ProjectMemberAPIEndpoint(BaseAPIView): user_data = UserLiteSerializer(user).data return Response(user_data, status=status.HTTP_201_CREATED) - diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 67ccf13a9..9e4f4143c 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -21,7 +21,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, Module, ModuleIssue, @@ -43,9 +43,7 @@ class ModuleAPIEndpoint(BaseAPIView): """ model = Module - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] serializer_class = ModuleSerializer webhook_event = "module" @@ -60,9 +58,7 @@ class ModuleAPIEndpoint(BaseAPIView): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) .annotate( @@ -71,9 +67,10 @@ class ModuleAPIEndpoint(BaseAPIView): filter=Q( issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, - ), + ) ) .annotate( completed_issues=Count( @@ -82,6 +79,7 @@ class ModuleAPIEndpoint(BaseAPIView): issue_module__issue__state__group="completed", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -93,6 +91,7 @@ class ModuleAPIEndpoint(BaseAPIView): issue_module__issue__state__group="cancelled", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -104,6 +103,7 @@ class ModuleAPIEndpoint(BaseAPIView): issue_module__issue__state__group="started", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -115,6 +115,7 @@ class ModuleAPIEndpoint(BaseAPIView): issue_module__issue__state__group="unstarted", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -126,6 +127,7 @@ class ModuleAPIEndpoint(BaseAPIView): issue_module__issue__state__group="backlog", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -137,10 +139,7 @@ class ModuleAPIEndpoint(BaseAPIView): project = Project.objects.get(pk=project_id, workspace__slug=slug) serializer = ModuleSerializer( data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - }, + context={"project_id": project_id, "workspace_id": project.workspace_id}, ) if serializer.is_valid(): if ( @@ -183,9 +182,7 @@ class ModuleAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def patch(self, request, slug, project_id, pk): - module = Module.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) current_instance = json.dumps( ModuleSerializer(module).data, cls=DjangoJSONEncoder @@ -197,10 +194,7 @@ class ModuleAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) serializer = ModuleSerializer( - module, - data=request.data, - context={"project_id": project_id}, - partial=True, + module, data=request.data, context={"project_id": project_id}, partial=True ) if serializer.is_valid(): if ( @@ -240,33 +234,21 @@ class ModuleAPIEndpoint(BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: - queryset = ( - self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) - ) + queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) data = ModuleSerializer( - queryset, - fields=self.fields, - expand=self.expand, + queryset, fields=self.fields, expand=self.expand ).data - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) return self.paginate( request=request, queryset=(self.get_queryset().filter(archived_at__isnull=True)), on_results=lambda modules: ModuleSerializer( - modules, - many=True, - fields=self.fields, - expand=self.expand, + modules, many=True, fields=self.fields, expand=self.expand ).data, ) def delete(self, request, slug, project_id, pk): - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if module.created_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, @@ -282,9 +264,7 @@ class ModuleAPIEndpoint(BaseAPIView): ) module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list( - "issue", flat=True - ) + ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) ) issue_activity.delay( type="module.activity.deleted", @@ -298,24 +278,15 @@ class ModuleAPIEndpoint(BaseAPIView): actor_id=str(request.user.id), issue_id=None, project_id=str(project_id), - current_instance=json.dumps( - { - "module_name": str(module.name), - } - ), + current_instance=json.dumps({"module_name": str(module.name)}), epoch=int(timezone.now().timestamp()), ) module.delete() # Delete the module issues - ModuleIssue.objects.filter( - module=pk, - project_id=project_id, - ).delete() + ModuleIssue.objects.filter(module=pk, project_id=project_id).delete() # Delete the user favorite module UserFavorite.objects.filter( - entity_type="module", - entity_identifier=pk, - project_id=project_id, + entity_type="module", entity_identifier=pk, project_id=project_id ).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -332,16 +303,12 @@ class ModuleIssueAPIEndpoint(BaseAPIView): webhook_event = "module_issue" bulk = True - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( ModuleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("issue") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -367,11 +334,11 @@ class ModuleIssueAPIEndpoint(BaseAPIView): def get(self, request, slug, project_id, module_id): order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter(issue_module__module_id=module_id) + Issue.issue_objects.filter( + issue_module__module_id=module_id, issue_module__deleted_at__isnull=True + ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -393,8 +360,9 @@ class ModuleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -405,10 +373,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView): request=request, queryset=(issues), on_results=lambda issues: IssueSerializer( - issues, - many=True, - fields=self.fields, - expand=self.expand, + issues, many=True, fields=self.fields, expand=self.expand ).data, ) @@ -416,8 +381,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView): issues = request.data.get("issues", []) if not len(issues): return Response( - {"error": "Issues are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=module_id @@ -464,16 +428,10 @@ class ModuleIssueAPIEndpoint(BaseAPIView): ) ModuleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, + record_to_create, batch_size=10, ignore_conflicts=True ) - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], - batch_size=10, - ) + ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10) # Capture Issue Activity issue_activity.delay( @@ -509,10 +467,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView): issue_activity.delay( type="module.activity.deleted", requested_data=json.dumps( - { - "module_id": str(module_id), - "issues": [str(module_issue.issue_id)], - } + {"module_id": str(module_id), "issues": [str(module_issue.issue_id)]} ), actor_id=str(request.user.id), issue_id=str(issue_id), @@ -524,9 +479,7 @@ class ModuleIssueAPIEndpoint(BaseAPIView): class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( @@ -540,9 +493,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) .annotate( @@ -551,9 +502,10 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): filter=Q( issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, - ), + ) ) .annotate( completed_issues=Count( @@ -562,6 +514,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_module__issue__state__group="completed", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -573,6 +526,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_module__issue__state__group="cancelled", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -584,6 +538,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_module__issue__state__group="started", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -595,6 +550,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_module__issue__state__group="unstarted", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -606,6 +562,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): issue_module__issue__state__group="backlog", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -618,22 +575,15 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): request=request, queryset=(self.get_queryset()), on_results=lambda modules: ModuleSerializer( - modules, - many=True, - fields=self.fields, - expand=self.expand, + modules, many=True, fields=self.fields, expand=self.expand ).data, ) def post(self, request, slug, project_id, pk): - module = Module.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) if module.status not in ["completed", "cancelled"]: return Response( - { - "error": "Only completed or cancelled modules can be archived" - }, + {"error": "Only completed or cancelled modules can be archived"}, status=status.HTTP_400_BAD_REQUEST, ) module.archived_at = timezone.now() @@ -647,9 +597,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def delete(self, request, slug, project_id, pk): - module = Module.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) module.archived_at = None module.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 594329a44..fa645a63c 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -18,7 +18,7 @@ from plane.app.permissions import ProjectBasePermission # Module imports from plane.db.models import ( Cycle, - Inbox, + Intake, IssueUserProperty, Module, Project, @@ -39,9 +39,7 @@ class ProjectAPIEndpoint(BaseAPIView): model = Project webhook_event = "project" - permission_classes = [ - ProjectBasePermission, - ] + permission_classes = [ProjectBasePermission] def get_queryset(self): return ( @@ -54,10 +52,7 @@ class ProjectAPIEndpoint(BaseAPIView): | Q(network=2) ) .select_related( - "workspace", - "workspace__owner", - "default_assignee", - "project_lead", + "workspace", "workspace__owner", "default_assignee", "project_lead" ) .annotate( is_member=Exists( @@ -71,9 +66,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) .annotate( total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), - member__is_bot=False, - is_active=True, + project_id=OuterRef("id"), member__is_bot=False, is_active=True ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -125,8 +118,7 @@ class ProjectAPIEndpoint(BaseAPIView): Prefetch( "project_projectmember", queryset=ProjectMember.objects.filter( - workspace__slug=slug, - is_active=True, + workspace__slug=slug, is_active=True ).select_related("member"), ) ) @@ -136,18 +128,11 @@ class ProjectAPIEndpoint(BaseAPIView): request=request, queryset=(projects), on_results=lambda projects: ProjectSerializer( - projects, - many=True, - fields=self.fields, - expand=self.expand, + projects, many=True, fields=self.fields, expand=self.expand ).data, ) project = self.get_queryset().get(workspace__slug=slug, pk=pk) - serializer = ProjectSerializer( - project, - fields=self.fields, - expand=self.expand, - ) + serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug): @@ -161,14 +146,11 @@ class ProjectAPIEndpoint(BaseAPIView): # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], - member=request.user, - role=20, + project_id=serializer.data["id"], member=request.user, role=20 ) # Also create the issue property for the user _ = IssueUserProperty.objects.create( - project_id=serializer.data["id"], - user=request.user, + project_id=serializer.data["id"], user=request.user ) if serializer.data["project_lead"] is not None and str( @@ -236,11 +218,7 @@ class ProjectAPIEndpoint(BaseAPIView): ] ) - project = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) - .first() - ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() # Model activity model_activity.delay( @@ -254,13 +232,8 @@ class ProjectAPIEndpoint(BaseAPIView): ) serializer = ProjectSerializer(project) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -269,8 +242,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) except Workspace.DoesNotExist: return Response( - {"error": "Workspace does not exist"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND ) except ValidationError: return Response( @@ -285,6 +257,9 @@ class ProjectAPIEndpoint(BaseAPIView): current_instance = json.dumps( ProjectSerializer(project).data, cls=DjangoJSONEncoder ) + + intake_view = request.data.get("inbox_view", project.intake_view) + if project.archived_at: return Response( {"error": "Archived project cannot be updated"}, @@ -293,21 +268,20 @@ class ProjectAPIEndpoint(BaseAPIView): serializer = ProjectSerializer( project, - data={**request.data}, + data={**request.data, "intake_view": intake_view}, context={"workspace_id": workspace.id}, partial=True, ) if serializer.is_valid(): serializer.save() - if serializer.data["inbox_view"]: - inbox = Inbox.objects.filter( - project=project, - is_default=True, + if serializer.data["intake_view"]: + intake = Intake.objects.filter( + project=project, is_default=True ).first() - if not inbox: - Inbox.objects.create( - name=f"{project.name} Inbox", + if not intake: + Intake.objects.create( + name=f"{project.name} Intake", project=project, is_default=True, ) @@ -316,17 +290,13 @@ class ProjectAPIEndpoint(BaseAPIView): State.objects.get_or_create( name="Triage", group="triage", - description="Default state for managing all Inbox Issues", + description="Default state for managing all Intake Issues", project_id=pk, color="#ff7700", is_triage=True, ) - project = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) - .first() - ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() model_activity.delay( model_name="project", @@ -340,9 +310,7 @@ class ProjectAPIEndpoint(BaseAPIView): serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -351,8 +319,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( - {"error": "Project does not exist"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) except ValidationError: return Response( @@ -364,28 +331,20 @@ class ProjectAPIEndpoint(BaseAPIView): project = Project.objects.get(pk=pk, workspace__slug=slug) # Delete the user favorite cycle UserFavorite.objects.filter( - entity_type="project", - entity_identifier=pk, - project_id=pk, + entity_type="project", entity_identifier=pk, project_id=pk ).delete() project.delete() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): - - permission_classes = [ - ProjectBasePermission, - ] + permission_classes = [ProjectBasePermission] def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() project.save() - UserFavorite.objects.filter( - workspace__slug=slug, - project=project_id, - ).delete() + UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() return Response(status=status.HTTP_204_NO_CONTENT) def delete(self, request, slug, project_id): diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index dd239754c..0fbbd222a 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -16,9 +16,7 @@ from .base import BaseAPIView class StateAPIEndpoint(BaseAPIView): serializer_class = StateSerializer model = State - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( @@ -67,9 +65,7 @@ class StateAPIEndpoint(BaseAPIView): serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: state = State.objects.filter( workspace__slug=slug, @@ -96,19 +92,13 @@ class StateAPIEndpoint(BaseAPIView): request=request, queryset=(self.get_queryset()), on_results=lambda states: StateSerializer( - states, - many=True, - fields=self.fields, - expand=self.expand, + states, many=True, fields=self.fields, expand=self.expand ).data, ) def delete(self, request, slug, project_id, state_id): state = State.objects.get( - is_triage=False, - pk=state_id, - project_id=project_id, - workspace__slug=slug, + is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug ) if state.default: @@ -122,9 +112,7 @@ class StateAPIEndpoint(BaseAPIView): if issue_exist: return Response( - { - "error": "The state is not empty, only empty states can be deleted" - }, + {"error": "The state is not empty, only empty states can be deleted"}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/middleware/api_authentication.py b/apiserver/plane/app/middleware/api_authentication.py index 893df7f84..ddabb4132 100644 --- a/apiserver/plane/app/middleware/api_authentication.py +++ b/apiserver/plane/app/middleware/api_authentication.py @@ -25,10 +25,7 @@ class APIKeyAuthentication(authentication.BaseAuthentication): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q( - Q(expired_at__gt=timezone.now()) - | Q(expired_at__isnull=True) - ), + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), token=token, is_active=True, ) diff --git a/apiserver/plane/app/permissions/__init__.py b/apiserver/plane/app/permissions/__init__.py index e45388144..b7a095e74 100644 --- a/apiserver/plane/app/permissions/__init__.py +++ b/apiserver/plane/app/permissions/__init__.py @@ -12,4 +12,4 @@ from .project import ( ProjectMemberPermission, ProjectLitePermission, ) -from .base import allow_permission, ROLE \ No newline at end of file +from .base import allow_permission, ROLE diff --git a/apiserver/plane/app/permissions/base.py b/apiserver/plane/app/permissions/base.py index 06faeceb6..7ba12a2e2 100644 --- a/apiserver/plane/app/permissions/base.py +++ b/apiserver/plane/app/permissions/base.py @@ -5,6 +5,7 @@ from rest_framework import status from enum import Enum + class ROLE(Enum): ADMIN = 20 MEMBER = 15 @@ -15,7 +16,6 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): def decorator(view_func): @wraps(view_func) def _wrapped_view(instance, request, *args, **kwargs): - # Check for creator if required if creator and model: obj = model.objects.filter( @@ -26,8 +26,7 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): # Convert allowed_roles to their values if they are enum members allowed_role_values = [ - role.value if isinstance(role, ROLE) else role - for role in allowed_roles + role.value if isinstance(role, ROLE) else role for role in allowed_roles ] # Check role permissions diff --git a/apiserver/plane/app/permissions/project.py b/apiserver/plane/app/permissions/project.py index 11eab008b..470960fcc 100644 --- a/apiserver/plane/app/permissions/project.py +++ b/apiserver/plane/app/permissions/project.py @@ -18,9 +18,7 @@ class ProjectBasePermission(BasePermission): ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: return WorkspaceMember.objects.filter( - workspace__slug=view.workspace_slug, - member=request.user, - is_active=True, + workspace__slug=view.workspace_slug, member=request.user, is_active=True ).exists() ## Only workspace owners or admins can create the projects @@ -50,9 +48,7 @@ class ProjectMemberPermission(BasePermission): ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: return ProjectMember.objects.filter( - workspace__slug=view.workspace_slug, - member=request.user, - is_active=True, + workspace__slug=view.workspace_slug, member=request.user, is_active=True ).exists() ## Only workspace owners or admins can create the projects if request.method == "POST": diff --git a/apiserver/plane/app/permissions/workspace.py b/apiserver/plane/app/permissions/workspace.py index c2dcc9f95..8dc791c0c 100644 --- a/apiserver/plane/app/permissions/workspace.py +++ b/apiserver/plane/app/permissions/workspace.py @@ -50,9 +50,7 @@ class WorkspaceOwnerPermission(BasePermission): return False return WorkspaceMember.objects.filter( - workspace__slug=view.workspace_slug, - member=request.user, - role=Admin, + workspace__slug=view.workspace_slug, member=request.user, role=Admin ).exists() @@ -77,9 +75,7 @@ class WorkspaceEntityPermission(BasePermission): ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: return WorkspaceMember.objects.filter( - workspace__slug=view.workspace_slug, - member=request.user, - is_active=True, + workspace__slug=view.workspace_slug, member=request.user, is_active=True ).exists() return WorkspaceMember.objects.filter( @@ -96,9 +92,7 @@ class WorkspaceViewerPermission(BasePermission): return False return WorkspaceMember.objects.filter( - member=request.user, - workspace__slug=view.workspace_slug, - is_active=True, + member=request.user, workspace__slug=view.workspace_slug, is_active=True ).exists() @@ -108,7 +102,5 @@ class WorkspaceUserPermission(BasePermission): return False return WorkspaceMember.objects.filter( - member=request.user, - workspace__slug=view.workspace_slug, - is_active=True, + member=request.user, workspace__slug=view.workspace_slug, is_active=True ).exists() diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 618a9ec20..cd9adb939 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -13,7 +13,6 @@ from .user import ( from .workspace import ( WorkSpaceSerializer, WorkSpaceMemberSerializer, - TeamSerializer, WorkSpaceMemberInviteSerializer, WorkspaceLiteSerializer, WorkspaceThemeSerializer, @@ -36,9 +35,7 @@ from .project import ( ProjectMemberRoleSerializer, ) from .state import StateSerializer, StateLiteSerializer -from .view import ( - IssueViewSerializer, -) +from .view import IssueViewSerializer from .cycle import ( CycleSerializer, CycleIssueSerializer, @@ -57,7 +54,7 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, - IssueInboxSerializer, + IssueIntakeSerializer, IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, @@ -102,20 +99,17 @@ from .estimate import ( WorkspaceEstimateSerializer, ) -from .inbox import ( - InboxSerializer, - InboxIssueSerializer, - IssueStateInboxSerializer, - InboxIssueLiteSerializer, - InboxIssueDetailSerializer, +from .intake import ( + IntakeSerializer, + IntakeIssueSerializer, + IssueStateIntakeSerializer, + IntakeIssueLiteSerializer, + IntakeIssueDetailSerializer, ) from .analytic import AnalyticViewSerializer -from .notification import ( - NotificationSerializer, - UserNotificationPreferenceSerializer, -) +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer from .exporter import ExporterHistorySerializer @@ -124,3 +118,9 @@ from .webhook import WebhookSerializer, WebhookLogSerializer from .dashboard import DashboardSerializer, WidgetSerializer from .favorite import UserFavoriteSerializer + +from .draft import ( + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) diff --git a/apiserver/plane/app/serializers/analytic.py b/apiserver/plane/app/serializers/analytic.py index 9f3ee6d0a..13b24d14d 100644 --- a/apiserver/plane/app/serializers/analytic.py +++ b/apiserver/plane/app/serializers/analytic.py @@ -7,10 +7,7 @@ class AnalyticViewSerializer(BaseSerializer): class Meta: model = AnalyticView fields = "__all__" - read_only_fields = [ - "workspace", - "query", - ] + read_only_fields = ["workspace", "query"] def create(self, validated_data): query_params = validated_data.get("query_dict", {}) diff --git a/apiserver/plane/app/serializers/asset.py b/apiserver/plane/app/serializers/asset.py index 136e2264b..560cd3538 100644 --- a/apiserver/plane/app/serializers/asset.py +++ b/apiserver/plane/app/serializers/asset.py @@ -6,9 +6,4 @@ class FileAssetSerializer(BaseSerializer): class Meta: model = FileAsset fields = "__all__" - read_only_fields = [ - "created_by", - "updated_by", - "created_at", - "updated_at", - ] + read_only_fields = ["created_by", "updated_by", "created_at", "updated_at"] diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 6693ba931..715ad6eae 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -60,10 +60,10 @@ class DynamicBaseSerializer(BaseSerializer): CycleIssueSerializer, IssueLiteSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer, + IntakeIssueLiteSerializer, IssueReactionLiteSerializer, - IssueAttachmentLiteSerializer, IssueLinkLiteSerializer, + RelatedIssueSerializer, ) # Expansion mapper @@ -84,13 +84,14 @@ class DynamicBaseSerializer(BaseSerializer): "issue_cycle": CycleIssueSerializer, "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox": InboxIssueLiteSerializer, + "issue_intake": IntakeIssueLiteSerializer, + "issue_related": RelatedIssueSerializer, "issue_reactions": IssueReactionLiteSerializer, - "issue_attachment": IssueAttachmentLiteSerializer, "issue_link": IssueLinkLiteSerializer, "sub_issues": IssueLiteSerializer, } + if field not in self.fields and field in expansion: self.fields[field] = expansion[field]( many=( True @@ -101,11 +102,12 @@ class DynamicBaseSerializer(BaseSerializer): "labels", "issue_cycle", "issue_relation", - "issue_inbox", + "issue_intake", "issue_reactions", "issue_attachment", "issue_link", "sub_issues", + "issue_related", ] else False ) @@ -130,11 +132,12 @@ class DynamicBaseSerializer(BaseSerializer): LabelSerializer, CycleIssueSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer, + IntakeIssueLiteSerializer, IssueLiteSerializer, IssueReactionLiteSerializer, IssueAttachmentLiteSerializer, IssueLinkLiteSerializer, + RelatedIssueSerializer, ) # Expansion mapper @@ -155,7 +158,8 @@ class DynamicBaseSerializer(BaseSerializer): "issue_cycle": CycleIssueSerializer, "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox": InboxIssueLiteSerializer, + "issue_intake": IntakeIssueLiteSerializer, + "issue_related": RelatedIssueSerializer, "issue_reactions": IssueReactionLiteSerializer, "issue_attachment": IssueAttachmentLiteSerializer, "issue_link": IssueLinkLiteSerializer, @@ -174,8 +178,26 @@ class DynamicBaseSerializer(BaseSerializer): response[expand] = exp_serializer.data else: # You might need to handle this case differently - response[expand] = getattr( - instance, f"{expand}_id", None - ) + response[expand] = getattr(instance, f"{expand}_id", None) + + # Check if issue_attachments is in fields or expand + if "issue_attachments" in self.fields or "issue_attachments" in self.expand: + # Import the model here to avoid circular imports + from plane.db.models import FileAsset + + issue_id = getattr(instance, "id", None) + + if issue_id: + # Fetch related issue_attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + # Serialize issue_attachments and add them to the response + response["issue_attachments"] = IssueAttachmentLiteSerializer( + issue_attachments, many=True + ).data + else: + response["issue_attachments"] = [] return response diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 97fd47960..bf08de4fe 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -4,11 +4,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from .issue import IssueStateSerializer -from plane.db.models import ( - Cycle, - CycleIssue, - CycleUserProperties, -) +from plane.db.models import Cycle, CycleIssue, CycleUserProperties class CycleWriteSerializer(BaseSerializer): @@ -18,20 +14,13 @@ class CycleWriteSerializer(BaseSerializer): and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed end date" - ) + raise serializers.ValidationError("Start date cannot exceed end date") return data class Meta: model = Cycle fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "owned_by", - "archived_at", - ] + read_only_fields = ["workspace", "project", "owned_by", "archived_at"] class CycleSerializer(BaseSerializer): @@ -87,18 +76,11 @@ class CycleIssueSerializer(BaseSerializer): class Meta: model = CycleIssue fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "cycle", - ] + read_only_fields = ["workspace", "project", "cycle"] + class CycleUserPropertiesSerializer(BaseSerializer): class Meta: model = CycleUserProperties fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "cycle" "user", - ] + read_only_fields = ["workspace", "project", "cycle" "user"] diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py new file mode 100644 index 000000000..f30835263 --- /dev/null +++ b/apiserver/plane/app/serializers/draft.py @@ -0,0 +1,271 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + User, + Issue, + Label, + State, + DraftIssue, + DraftIssueAssignee, + DraftIssueLabel, + DraftIssueCycle, + DraftIssueModule, +) + + +class DraftIssueCreateSerializer(BaseSerializer): + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", queryset=State.objects.all(), required=False, allow_null=True + ) + parent_id = serializers.PrimaryKeyRelatedField( + source="parent", queryset=Issue.objects.all(), required=False, allow_null=True + ) + label_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = DraftIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + modules = validated_data.pop("module_ids", None) + cycle_id = self.initial_data.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + workspace_id = self.context["workspace_id"] + project_id = self.context["project_id"] + + # Create Issue + issue = DraftIssue.objects.create( + **validated_data, workspace_id=workspace_id, project_id=project_id + ) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee=user, + draft_issue=issue, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None and len(labels): + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label=label, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + if cycle_id is not None: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None and len(modules): + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + cycle_id = self.context.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + # Related models + workspace_id = instance.workspace_id + project_id = instance.project_id + + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + DraftIssueAssignee.objects.filter(draft_issue=instance).delete() + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee=user, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + DraftIssueLabel.objects.filter(draft_issue=instance).delete() + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label=label, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + if cycle_id != "not_provided": + DraftIssueCycle.objects.filter(draft_issue=instance).delete() + if cycle_id: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None: + DraftIssueModule.objects.filter(draft_issue=instance).delete() + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + # Time updation occurs even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class DraftIssueSerializer(BaseSerializer): + # ids + cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + # Many to many + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = DraftIssue + fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + "type_id", + "description_html", + ] + read_only_fields = fields + + +class DraftIssueDetailSerializer(DraftIssueSerializer): + description_html = serializers.CharField() + + class Meta(DraftIssueSerializer.Meta): + fields = DraftIssueSerializer.Meta.fields + ["description_html"] + read_only_fields = fields diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index 8cb083ca5..b2d65ef8c 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -7,14 +7,10 @@ from rest_framework import serializers class EstimateSerializer(BaseSerializer): - class Meta: model = Estimate fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class EstimatePointSerializer(BaseSerializer): @@ -23,19 +19,13 @@ class EstimatePointSerializer(BaseSerializer): raise serializers.ValidationError("Estimate points are required") value = data.get("value") if value and len(value) > 20: - raise serializers.ValidationError( - "Value can't be more than 20 characters" - ) + raise serializers.ValidationError("Value can't be more than 20 characters") return data class Meta: model = EstimatePoint fields = "__all__" - read_only_fields = [ - "estimate", - "workspace", - "project", - ] + read_only_fields = ["estimate", "workspace", "project"] class EstimateReadSerializer(BaseSerializer): @@ -44,11 +34,7 @@ class EstimateReadSerializer(BaseSerializer): class Meta: model = Estimate fields = "__all__" - read_only_fields = [ - "points", - "name", - "description", - ] + read_only_fields = ["points", "name", "description"] class WorkspaceEstimateSerializer(BaseSerializer): @@ -57,8 +43,4 @@ class WorkspaceEstimateSerializer(BaseSerializer): class Meta: model = Estimate fields = "__all__" - read_only_fields = [ - "points", - "name", - "description", - ] + read_only_fields = ["points", "name", "description"] diff --git a/apiserver/plane/app/serializers/exporter.py b/apiserver/plane/app/serializers/exporter.py index 2dd850fd3..5c78cfa69 100644 --- a/apiserver/plane/app/serializers/exporter.py +++ b/apiserver/plane/app/serializers/exporter.py @@ -5,9 +5,7 @@ from .user import UserLiteSerializer class ExporterHistorySerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer( - source="initiated_by", read_only=True - ) + initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) class Meta: model = ExporterHistory diff --git a/apiserver/plane/app/serializers/favorite.py b/apiserver/plane/app/serializers/favorite.py index 8e0beda10..940b8ee82 100644 --- a/apiserver/plane/app/serializers/favorite.py +++ b/apiserver/plane/app/serializers/favorite.py @@ -1,18 +1,9 @@ from rest_framework import serializers -from plane.db.models import ( - UserFavorite, - Cycle, - Module, - Issue, - IssueView, - Page, - Project, -) +from plane.db.models import UserFavorite, Cycle, Module, Issue, IssueView, Page, Project class ProjectFavoriteLiteSerializer(serializers.ModelSerializer): - class Meta: model = Project fields = ["id", "name", "logo_props"] @@ -33,21 +24,18 @@ class PageFavoriteLiteSerializer(serializers.ModelSerializer): class CycleFavoriteLiteSerializer(serializers.ModelSerializer): - class Meta: model = Cycle fields = ["id", "name", "logo_props", "project_id"] class ModuleFavoriteLiteSerializer(serializers.ModelSerializer): - class Meta: model = Module fields = ["id", "name", "logo_props", "project_id"] class ViewFavoriteSerializer(serializers.ModelSerializer): - class Meta: model = IssueView fields = ["id", "name", "logo_props", "project_id"] @@ -89,9 +77,7 @@ class UserFavoriteSerializer(serializers.ModelSerializer): entity_type = obj.entity_type entity_identifier = obj.entity_identifier - entity_model, entity_serializer = get_entity_model_and_serializer( - entity_type - ) + entity_model, entity_serializer = get_entity_model_and_serializer(entity_type) if entity_model and entity_serializer: try: entity = entity_model.objects.get(pk=entity_identifier) diff --git a/apiserver/plane/app/serializers/importer.py b/apiserver/plane/app/serializers/importer.py index c058994d6..8997f6392 100644 --- a/apiserver/plane/app/serializers/importer.py +++ b/apiserver/plane/app/serializers/importer.py @@ -7,13 +7,9 @@ from plane.db.models import Importer class ImporterSerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer( - source="initiated_by", read_only=True - ) + initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) class Meta: model = Importer diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/intake.py similarity index 64% rename from apiserver/plane/app/serializers/inbox.py rename to apiserver/plane/app/serializers/intake.py index e0c18b3d1..8b8bbacf7 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/intake.py @@ -3,35 +3,28 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import ( - IssueInboxSerializer, - LabelLiteSerializer, - IssueDetailSerializer, -) +from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerializer from .project import ProjectLiteSerializer from .state import StateLiteSerializer from .user import UserLiteSerializer -from plane.db.models import Inbox, InboxIssue, Issue +from plane.db.models import Intake, IntakeIssue, Issue -class InboxSerializer(BaseSerializer): +class IntakeSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(source="project", read_only=True) pending_issue_count = serializers.IntegerField(read_only=True) class Meta: - model = Inbox + model = Intake fields = "__all__" - read_only_fields = [ - "project", - "workspace", - ] + read_only_fields = ["project", "workspace"] -class InboxIssueSerializer(BaseSerializer): - issue = IssueInboxSerializer(read_only=True) +class IntakeIssueSerializer(BaseSerializer): + issue = IssueIntakeSerializer(read_only=True) class Meta: - model = InboxIssue + model = IntakeIssue fields = [ "id", "status", @@ -41,10 +34,7 @@ class InboxIssueSerializer(BaseSerializer): "issue", "created_by", ] - read_only_fields = [ - "project", - "workspace", - ] + read_only_fields = ["project", "workspace"] def to_representation(self, instance): # Pass the annotated fields to the Issue instance if they exist @@ -53,14 +43,14 @@ class InboxIssueSerializer(BaseSerializer): return super().to_representation(instance) -class InboxIssueDetailSerializer(BaseSerializer): +class IntakeIssueDetailSerializer(BaseSerializer): issue = IssueDetailSerializer(read_only=True) - duplicate_issue_detail = IssueInboxSerializer( + duplicate_issue_detail = IssueIntakeSerializer( read_only=True, source="duplicate_to" ) class Meta: - model = InboxIssue + model = IntakeIssue fields = [ "id", "status", @@ -70,10 +60,7 @@ class InboxIssueDetailSerializer(BaseSerializer): "source", "issue", ] - read_only_fields = [ - "project", - "workspace", - ] + read_only_fields = ["project", "workspace"] def to_representation(self, instance): # Pass the annotated fields to the Issue instance if they exist @@ -85,24 +72,20 @@ class InboxIssueDetailSerializer(BaseSerializer): return super().to_representation(instance) -class InboxIssueLiteSerializer(BaseSerializer): +class IntakeIssueLiteSerializer(BaseSerializer): class Meta: - model = InboxIssue + model = IntakeIssue fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] read_only_fields = fields -class IssueStateInboxSerializer(BaseSerializer): +class IssueStateIntakeSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) - issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + issue_intake = IntakeIssueLiteSerializer(read_only=True, many=True) class Meta: model = Issue diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 4cdf94402..007f34849 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -27,7 +27,7 @@ from plane.db.models import ( Module, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -60,12 +60,7 @@ class IssueProjectLiteSerializer(BaseSerializer): class Meta: model = Issue - fields = [ - "id", - "project_detail", - "name", - "sequence_id", - ] + fields = ["id", "project_detail", "name", "sequence_id"] read_only_fields = fields @@ -74,16 +69,10 @@ class IssueProjectLiteSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer): # ids state_id = serializers.PrimaryKeyRelatedField( - source="state", - queryset=State.objects.all(), - required=False, - allow_null=True, + source="state", queryset=State.objects.all(), required=False, allow_null=True ) parent_id = serializers.PrimaryKeyRelatedField( - source="parent", - queryset=Issue.objects.all(), - required=False, - allow_null=True, + source="parent", queryset=Issue.objects.all(), required=False, allow_null=True ) label_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), @@ -95,6 +84,8 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + project_id = serializers.UUIDField(source="project.id", read_only=True) + workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) class Meta: model = Issue @@ -122,9 +113,7 @@ class IssueCreateSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed target date" - ) + raise serializers.ValidationError("Start date cannot exceed target date") return data def create(self, validated_data): @@ -136,10 +125,7 @@ class IssueCreateSerializer(BaseSerializer): default_assignee_id = self.context["default_assignee_id"] # Create Issue - issue = Issue.objects.create( - **validated_data, - project_id=project_id, - ) + issue = Issue.objects.create(**validated_data, project_id=project_id) # Issue Audit Users created_by_id = issue.created_by_id @@ -243,9 +229,7 @@ class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") class Meta: model = IssueActivity @@ -256,11 +240,7 @@ class IssueUserPropertySerializer(BaseSerializer): class Meta: model = IssueUserProperty fields = "__all__" - read_only_fields = [ - "user", - "workspace", - "project", - ] + read_only_fields = ["user", "workspace", "project"] class LabelSerializer(BaseSerializer): @@ -275,30 +255,20 @@ class LabelSerializer(BaseSerializer): "workspace_id", "sort_order", ] - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class LabelLiteSerializer(BaseSerializer): class Meta: model = Label - fields = [ - "id", - "name", - "color", - ] + fields = ["id", "name", "color"] class IssueLabelSerializer(BaseSerializer): class Meta: model = IssueLabel fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class IssueRelationSerializer(BaseSerializer): @@ -314,17 +284,8 @@ class IssueRelationSerializer(BaseSerializer): class Meta: model = IssueRelation - fields = [ - "id", - "project_id", - "sequence_id", - "relation_type", - "name", - ] - read_only_fields = [ - "workspace", - "project", - ] + fields = ["id", "project_id", "sequence_id", "relation_type", "name"] + read_only_fields = ["workspace", "project"] class RelatedIssueSerializer(BaseSerializer): @@ -332,25 +293,14 @@ class RelatedIssueSerializer(BaseSerializer): project_id = serializers.PrimaryKeyRelatedField( source="issue.project_id", read_only=True ) - sequence_id = serializers.IntegerField( - source="issue.sequence_id", read_only=True - ) + sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) name = serializers.CharField(source="issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) class Meta: model = IssueRelation - fields = [ - "id", - "project_id", - "sequence_id", - "relation_type", - "name", - ] - read_only_fields = [ - "workspace", - "project", - ] + fields = ["id", "project_id", "sequence_id", "relation_type", "name"] + read_only_fields = ["workspace", "project"] class IssueAssigneeSerializer(BaseSerializer): @@ -458,8 +408,7 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), - issue_id=validated_data.get("issue_id"), + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -469,8 +418,7 @@ class IssueLinkSerializer(BaseSerializer): def update(self, instance, validated_data): if ( IssueLink.objects.filter( - url=validated_data.get("url"), - issue_id=instance.issue_id, + url=validated_data.get("url"), issue_id=instance.issue_id ) .exclude(pk=instance.id) .exists() @@ -498,8 +446,10 @@ class IssueLinkLiteSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): + asset_url = serializers.CharField(read_only=True) + class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", @@ -514,14 +464,15 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentLiteSerializer(DynamicBaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = [ "id", "asset", "attributes", - "issue_id", + # "issue_id", "updated_at", "updated_by", + "asset_url", ] read_only_fields = fields @@ -532,37 +483,20 @@ class IssueReactionSerializer(BaseSerializer): class Meta: model = IssueReaction fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "actor", - "deleted_at", - ] + read_only_fields = ["workspace", "project", "issue", "actor", "deleted_at"] class IssueReactionLiteSerializer(DynamicBaseSerializer): class Meta: model = IssueReaction - fields = [ - "id", - "actor", - "issue", - "reaction", - ] + fields = ["id", "actor", "issue", "reaction"] class CommentReactionSerializer(BaseSerializer): class Meta: model = CommentReaction fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "comment", - "actor", - "deleted_at", - ] + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] class IssueVoteSerializer(BaseSerializer): @@ -570,14 +504,7 @@ class IssueVoteSerializer(BaseSerializer): class Meta: model = IssueVote - fields = [ - "issue", - "vote", - "workspace", - "project", - "actor", - "actor_detail", - ] + fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] read_only_fields = fields @@ -585,9 +512,7 @@ class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") comment_reactions = CommentReactionSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) @@ -611,25 +536,15 @@ class IssueStateFlatSerializer(BaseSerializer): class Meta: model = Issue - fields = [ - "id", - "sequence_id", - "name", - "state_detail", - "project_detail", - ] + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] # Issue Serializer with state details class IssueStateSerializer(DynamicBaseSerializer): - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) @@ -639,11 +554,8 @@ class IssueStateSerializer(DynamicBaseSerializer): fields = "__all__" -class IssueInboxSerializer(DynamicBaseSerializer): - label_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) +class IssueIntakeSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) class Meta: model = Issue @@ -663,20 +575,11 @@ class IssueInboxSerializer(DynamicBaseSerializer): class IssueSerializer(DynamicBaseSerializer): # ids cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) # Many to many - label_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) - assignee_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) # Count items sub_issues_count = serializers.IntegerField(read_only=True) @@ -718,11 +621,7 @@ class IssueSerializer(DynamicBaseSerializer): class IssueLiteSerializer(DynamicBaseSerializer): class Meta: model = Issue - fields = [ - "id", - "sequence_id", - "project_id", - ] + fields = ["id", "sequence_id", "project_id"] read_only_fields = fields @@ -731,10 +630,7 @@ class IssueDetailSerializer(IssueSerializer): is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + [ - "description_html", - "is_subscribed", - ] + fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] read_only_fields = fields @@ -770,8 +666,4 @@ class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - ] + read_only_fields = ["workspace", "project", "issue"] diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 48ace6022..22ff44279 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -21,10 +21,7 @@ from plane.db.models import ( class ModuleWriteSerializer(BaseSerializer): lead_id = serializers.PrimaryKeyRelatedField( - source="lead", - queryset=User.objects.all(), - required=False, - allow_null=True, + source="lead", queryset=User.objects.all(), required=False, allow_null=True ) member_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -48,9 +45,7 @@ class ModuleWriteSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data["member_ids"] = [ - str(member.id) for member in instance.members.all() - ] + data["member_ids"] = [str(member.id) for member in instance.members.all()] return data def validate(self, data): @@ -59,9 +54,7 @@ class ModuleWriteSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed target date" - ) + raise serializers.ValidationError("Start date cannot exceed target date") return data def create(self, validated_data): @@ -71,9 +64,7 @@ class ModuleWriteSerializer(BaseSerializer): module_name = validated_data.get("name") if module_name: # Lookup for the module name in the module table for that project - if Module.objects.filter( - name=module_name, project=project - ).exists(): + if Module.objects.filter(name=module_name, project=project).exists(): raise serializers.ValidationError( {"error": "Module with this name already exists"} ) @@ -104,9 +95,7 @@ class ModuleWriteSerializer(BaseSerializer): if module_name: # Lookup for the module name in the module table for that project if ( - Module.objects.filter( - name=module_name, project=instance.project - ) + Module.objects.filter(name=module_name, project=instance.project) .exclude(id=instance.id) .exists() ): @@ -203,8 +192,7 @@ class ModuleLinkSerializer(BaseSerializer): def create(self, validated_data): validated_data["url"] = self.validate_url(validated_data.get("url")) if ModuleLink.objects.filter( - url=validated_data.get("url"), - module_id=validated_data.get("module_id"), + url=validated_data.get("url"), module_id=validated_data.get("module_id") ).exists(): raise serializers.ValidationError({"error": "URL already exists."}) return super().create(validated_data) @@ -213,8 +201,7 @@ class ModuleLinkSerializer(BaseSerializer): validated_data["url"] = self.validate_url(validated_data.get("url")) if ( ModuleLink.objects.filter( - url=validated_data.get("url"), - module_id=instance.module_id, + url=validated_data.get("url"), module_id=instance.module_id ) .exclude(pk=instance.id) .exists() diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index a99b63e0d..58007ec26 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -8,10 +8,9 @@ from rest_framework import serializers class NotificationSerializer(BaseSerializer): - triggered_by_details = UserLiteSerializer( - read_only=True, source="triggered_by" - ) + triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") is_inbox_issue = serializers.BooleanField(read_only=True) + is_intake_issue = serializers.BooleanField(read_only=True) is_mentioned_notification = serializers.BooleanField(read_only=True) class Meta: diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index e7f273d40..b69221081 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -22,14 +22,8 @@ class PageSerializer(BaseSerializer): required=False, ) # Many to many - label_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) - project_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + project_ids = serializers.ListField(child=serializers.UUIDField(), required=False) class Meta: model = Page @@ -54,10 +48,7 @@ class PageSerializer(BaseSerializer): "label_ids", "project_ids", ] - read_only_fields = [ - "workspace", - "owned_by", - ] + read_only_fields = ["workspace", "owned_by"] def create(self, validated_data): labels = validated_data.pop("labels", None) @@ -127,9 +118,7 @@ class PageDetailSerializer(PageSerializer): description_html = serializers.CharField() class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + [ - "description_html", - ] + fields = PageSerializer.Meta.fields + ["description_html"] class SubPageSerializer(BaseSerializer): @@ -138,10 +127,7 @@ class SubPageSerializer(BaseSerializer): class Meta: model = PageLog fields = "__all__" - read_only_fields = [ - "workspace", - "page", - ] + read_only_fields = ["workspace", "page"] def get_entity_details(self, obj): entity_name = obj.entity_name @@ -158,10 +144,7 @@ class PageLogSerializer(BaseSerializer): class Meta: model = PageLog fields = "__all__" - read_only_fields = [ - "workspace", - "page", - ] + read_only_fields = ["workspace", "page"] class PageVersionSerializer(BaseSerializer): @@ -178,10 +161,7 @@ class PageVersionSerializer(BaseSerializer): "created_by", "updated_by", ] - read_only_fields = [ - "workspace", - "page", - ] + read_only_fields = ["workspace", "page"] class PageVersionDetailSerializer(BaseSerializer): @@ -201,7 +181,4 @@ class PageVersionDetailSerializer(BaseSerializer): "created_by", "updated_by", ] - read_only_fields = [ - "workspace", - "page", - ] + read_only_fields = ["workspace", "page"] diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 948608f79..cc7b7fbcb 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -4,10 +4,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer, DynamicBaseSerializer from plane.app.serializers.workspace import WorkspaceLiteSerializer -from plane.app.serializers.user import ( - UserLiteSerializer, - UserAdminLiteSerializer, -) +from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( Project, ProjectMember, @@ -19,31 +16,23 @@ from plane.db.models import ( class ProjectSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + inbox_view = serializers.BooleanField(read_only=True, source="intake_view") class Meta: model = Project fields = "__all__" - read_only_fields = [ - "workspace", - "deleted_at", - ] + read_only_fields = ["workspace", "deleted_at"] def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError( - detail="Project Identifier is required" - ) + raise serializers.ValidationError(detail="Project Identifier is required") if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError( - detail="Project Identifier is taken" - ) + raise serializers.ValidationError(detail="Project Identifier is taken") project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] ) @@ -82,9 +71,7 @@ class ProjectSerializer(BaseSerializer): return project # If not same fail update - raise serializers.ValidationError( - detail="Project Identifier is already taken" - ) + raise serializers.ValidationError(detail="Project Identifier is already taken") class ProjectLiteSerializer(BaseSerializer): @@ -95,6 +82,7 @@ class ProjectLiteSerializer(BaseSerializer): "identifier", "name", "cover_image", + "cover_image_url", "logo_props", "description", ] @@ -117,6 +105,8 @@ class ProjectListSerializer(DynamicBaseSerializer): member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() + cover_image_url = serializers.CharField(read_only=True) + inbox_view = serializers.BooleanField(read_only=True, source="intake_view") def get_members(self, obj): project_members = getattr(obj, "members_list", None) @@ -128,6 +118,7 @@ class ProjectListSerializer(DynamicBaseSerializer): "member_id": member.member_id, "member__display_name": member.member.display_name, "member__avatar": member.member.avatar, + "member__avatar_url": member.member.avatar_url, } for member in project_members ] @@ -209,26 +200,16 @@ class ProjectMemberLiteSerializer(BaseSerializer): class DeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") class Meta: model = DeployBoard fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "anchor", - ] + read_only_fields = ["workspace", "project", "anchor"] class ProjectPublicMemberSerializer(BaseSerializer): class Meta: model = ProjectPublicMember fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "member", - ] + read_only_fields = ["workspace", "project", "member"] diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py index 773d8e461..61af5cab7 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -19,19 +19,11 @@ class StateSerializer(BaseSerializer): "description", "sequence", ] - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class StateLiteSerializer(BaseSerializer): class Meta: model = State - fields = [ - "id", - "name", - "color", - "group", - ] + fields = ["id", "name", "color", "group"] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index f99214874..ebc002c9c 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -2,13 +2,7 @@ from rest_framework import serializers # Module import -from plane.db.models import ( - Account, - Profile, - User, - Workspace, - WorkspaceMemberInvite, -) +from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite from .base import BaseSerializer @@ -17,11 +11,7 @@ class UserSerializer(BaseSerializer): class Meta: model = User # Exclude password field from the serializer - fields = [ - field.name - for field in User._meta.fields - if field.name != "password" - ] + fields = [field.name for field in User._meta.fields if field.name != "password"] # Make all system fields and email read only read_only_fields = [ "id", @@ -62,6 +52,8 @@ class UserMeSerializer(BaseSerializer): "id", "avatar", "cover_image", + "avatar_url", + "cover_image_url", "date_joined", "display_name", "email", @@ -84,11 +76,7 @@ class UserMeSettingsSerializer(BaseSerializer): class Meta: model = User - fields = [ - "id", - "email", - "workspace", - ] + fields = ["id", "email", "workspace"] read_only_fields = fields def get_workspace(self, obj): @@ -125,8 +113,7 @@ class UserMeSettingsSerializer(BaseSerializer): else: fallback_workspace = ( Workspace.objects.filter( - workspace_member__member_id=obj.id, - workspace_member__is_active=True, + workspace_member__member_id=obj.id, workspace_member__is_active=True ) .order_by("created_at") .first() @@ -135,14 +122,10 @@ class UserMeSettingsSerializer(BaseSerializer): "last_workspace_id": None, "last_workspace_slug": None, "fallback_workspace_id": ( - fallback_workspace.id - if fallback_workspace is not None - else None + fallback_workspace.id if fallback_workspace is not None else None ), "fallback_workspace_slug": ( - fallback_workspace.slug - if fallback_workspace is not None - else None + fallback_workspace.slug if fallback_workspace is not None else None ), "invites": workspace_invites, } @@ -156,13 +139,11 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] - read_only_fields = [ - "id", - "is_bot", - ] + read_only_fields = ["id", "is_bot"] class UserAdminLiteSerializer(BaseSerializer): @@ -173,15 +154,13 @@ class UserAdminLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", "email", "last_login_medium", ] - read_only_fields = [ - "id", - "is_bot", - ] + read_only_fields = ["id", "is_bot"] class ChangePasswordSerializer(serializers.Serializer): @@ -202,9 +181,7 @@ class ChangePasswordSerializer(serializers.Serializer): if data.get("new_password") != data.get("confirm_password"): raise serializers.ValidationError( - { - "error": "Confirm password should be same as the new password." - } + {"error": "Confirm password should be same as the new password."} ) return data @@ -222,15 +199,11 @@ class ProfileSerializer(BaseSerializer): class Meta: model = Profile fields = "__all__" - read_only_fields = [ - "user", - ] + read_only_fields = ["user"] class AccountSerializer(BaseSerializer): class Meta: model = Account fields = "__all__" - read_only_fields = [ - "user", - ] + read_only_fields = ["user"] diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 918d1705c..fa4019f7a 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -47,13 +47,9 @@ class WebhookSerializer(DynamicBaseSerializer): # Additional validation for multiple request domains and their subdomains request = self.context.get("request") - disallowed_domains = [ - "plane.so", - ] # Add your disallowed domains here + disallowed_domains = ["plane.so"] # Add your disallowed domains here if request: - request_host = request.get_host().split(":")[ - 0 - ] # Remove port if present + request_host = request.get_host().split(":")[0] # Remove port if present disallowed_domains.append(request_host) # Check if hostname is a subdomain or exact match of any disallowed domain @@ -99,9 +95,7 @@ class WebhookSerializer(DynamicBaseSerializer): # Additional validation for multiple request domains and their subdomains request = self.context.get("request") - disallowed_domains = [ - "plane.so", - ] # Add your disallowed domains here + disallowed_domains = ["plane.so"] # Add your disallowed domains here if request: request_host = request.get_host().split(":")[ 0 @@ -122,10 +116,7 @@ class WebhookSerializer(DynamicBaseSerializer): class Meta: model = Webhook fields = "__all__" - read_only_fields = [ - "workspace", - "secret_key", - ] + read_only_fields = ["workspace", "secret_key"] class WebhookLogSerializer(DynamicBaseSerializer): diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 96ee7dce3..49cd55bf7 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( - User, Workspace, WorkspaceMember, - Team, - TeamMember, WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, @@ -22,6 +19,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) + logo_url = serializers.CharField(read_only=True) def validate_slug(self, value): # Check if the slug is restricted @@ -39,17 +37,14 @@ class WorkSpaceSerializer(DynamicBaseSerializer): "created_at", "updated_at", "owner", + "logo_url", ] class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace - fields = [ - "name", - "slug", - "id", - ] + fields = ["name", "slug", "id"] read_only_fields = fields @@ -63,6 +58,8 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer): class WorkspaceMemberMeSerializer(BaseSerializer): + draft_issue_count = serializers.IntegerField(read_only=True) + class Meta: model = WorkspaceMember fields = "__all__" @@ -97,71 +94,15 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): ] -class TeamSerializer(BaseSerializer): - members_detail = UserLiteSerializer( - read_only=True, source="members", many=True - ) - members = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = Team - fields = "__all__" - read_only_fields = [ - "workspace", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def create(self, validated_data, **kwargs): - if "members" in validated_data: - members = validated_data.pop("members") - workspace = self.context["workspace"] - team = Team.objects.create(**validated_data, workspace=workspace) - team_members = [ - TeamMember(member=member, team=team, workspace=workspace) - for member in members - ] - TeamMember.objects.bulk_create(team_members, batch_size=10) - return team - team = Team.objects.create(**validated_data) - return team - - def update(self, instance, validated_data): - if "members" in validated_data: - members = validated_data.pop("members") - TeamMember.objects.filter(team=instance).delete() - team_members = [ - TeamMember( - member=member, team=instance, workspace=instance.workspace - ) - for member in members - ] - TeamMember.objects.bulk_create(team_members, batch_size=10) - return super().update(instance, validated_data) - return super().update(instance, validated_data) - - class WorkspaceThemeSerializer(BaseSerializer): class Meta: model = WorkspaceTheme fields = "__all__" - read_only_fields = [ - "workspace", - "actor", - ] + read_only_fields = ["workspace", "actor"] class WorkspaceUserPropertiesSerializer(BaseSerializer): class Meta: model = WorkspaceUserProperties fields = "__all__" - read_only_fields = [ - "workspace", - "user", - ] + read_only_fields = ["workspace", "user"] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index cb5f0253a..8798e8044 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -5,7 +5,7 @@ from .cycle import urlpatterns as cycle_urls from .dashboard import urlpatterns as dashboard_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls -from .inbox import urlpatterns as inbox_urls +from .intake import urlpatterns as intake_urls from .issue import urlpatterns as issue_urls from .module import urlpatterns as module_urls from .notification import urlpatterns as notification_urls @@ -25,7 +25,7 @@ urlpatterns = [ *dashboard_urls, *estimate_urls, *external_urls, - *inbox_urls, + *intake_urls, *issue_urls, *module_urls, *notification_urls, diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index 2d84b93e0..ec0f41b62 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -5,6 +5,13 @@ from plane.app.views import ( FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet, + # V2 Endpoints + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, ) @@ -19,11 +26,7 @@ urlpatterns = [ FileAssetEndpoint.as_view(), name="file-assets", ), - path( - "users/file-assets/", - UserAssetsEndpoint.as_view(), - name="user-file-assets", - ), + path("users/file-assets/", UserAssetsEndpoint.as_view(), name="user-file-assets"), path( "users/file-assets//", UserAssetsEndpoint.as_view(), @@ -31,11 +34,52 @@ urlpatterns = [ ), path( "workspaces/file-assets///restore/", - FileAssetViewSet.as_view( - { - "post": "restore", - } - ), + FileAssetViewSet.as_view({"post": "restore"}), name="file-assets-restore", ), + # V2 Endpoints + path( + "assets/v2/workspaces//", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/workspaces///", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/user-assets/", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/user-assets//", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/workspaces//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/static//", + StaticFileAssetEndpoint.as_view(), + name="static-file-asset", + ), + path( + "assets/v2/workspaces//projects//", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///bulk/", + ProjectBulkAssetEndpoint.as_view(), + ), ] diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index 0d62d0271..f188d0872 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -17,12 +17,7 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//projects//cycles/", - CycleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + CycleViewSet.as_view({"get": "list", "post": "create"}), name="project-cycle", ), path( @@ -39,12 +34,7 @@ urlpatterns = [ ), path( "workspaces//projects//cycles//cycle-issues/", - CycleIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + CycleIssueViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-cycle", ), path( @@ -66,21 +56,12 @@ urlpatterns = [ ), path( "workspaces//projects//user-favorite-cycles/", - CycleFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + CycleFavoriteViewSet.as_view({"get": "list", "post": "create"}), name="user-favorite-cycle", ), path( "workspaces//projects//user-favorite-cycles//", - CycleFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), + CycleFavoriteViewSet.as_view({"delete": "destroy"}), name="user-favorite-cycle", ), path( diff --git a/apiserver/plane/app/urls/estimate.py b/apiserver/plane/app/urls/estimate.py index 7db94aa46..8e5af2a85 100644 --- a/apiserver/plane/app/urls/estimate.py +++ b/apiserver/plane/app/urls/estimate.py @@ -16,42 +16,24 @@ urlpatterns = [ ), path( "workspaces//projects//estimates/", - BulkEstimatePointEndpoint.as_view( - { - "get": "list", - "post": "create", - } - ), + BulkEstimatePointEndpoint.as_view({"get": "list", "post": "create"}), name="bulk-create-estimate-points", ), path( "workspaces//projects//estimates//", BulkEstimatePointEndpoint.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="bulk-create-estimate-points", ), path( "workspaces//projects//estimates//estimate-points/", - EstimatePointEndpoint.as_view( - { - "post": "create", - } - ), + EstimatePointEndpoint.as_view({"post": "create"}), name="estimate-points", ), path( "workspaces//projects//estimates//estimate-points//", - EstimatePointEndpoint.as_view( - { - "patch": "partial_update", - "delete": "destroy", - } - ), + EstimatePointEndpoint.as_view({"patch": "partial_update", "delete": "destroy"}), name="estimate-points", ), ] diff --git a/apiserver/plane/app/urls/external.py b/apiserver/plane/app/urls/external.py index 744c646ca..4972962d8 100644 --- a/apiserver/plane/app/urls/external.py +++ b/apiserver/plane/app/urls/external.py @@ -6,17 +6,13 @@ from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpo urlpatterns = [ - path( - "unsplash/", - UnsplashEndpoint.as_view(), - name="unsplash", - ), + path("unsplash/", UnsplashEndpoint.as_view(), name="unsplash"), path( "workspaces//projects//ai-assistant/", GPTIntegrationEndpoint.as_view(), name="importer", ), - path( + path( "workspaces//ai-assistant/", WorkspaceGPTIntegrationEndpoint.as_view(), name="importer", diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py deleted file mode 100644 index 6508c001d..000000000 --- a/apiserver/plane/app/urls/inbox.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.urls import path - - -from plane.app.views import ( - InboxViewSet, - InboxIssueViewSet, -) - - -urlpatterns = [ - path( - "workspaces//projects//inboxes/", - InboxViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inboxes//", - InboxViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox", - ), - path( - "workspaces//projects//inbox-issues/", - InboxIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox-issue", - ), - path( - "workspaces//projects//inbox-issues//", - InboxIssueViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox-issue", - ), -] diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py new file mode 100644 index 000000000..397579262 --- /dev/null +++ b/apiserver/plane/app/urls/intake.py @@ -0,0 +1,56 @@ +from django.urls import path + + +from plane.app.views import IntakeViewSet, IntakeIssueViewSet + + +urlpatterns = [ + path( + "workspaces//projects//intakes/", + IntakeViewSet.as_view({"get": "list", "post": "create"}), + name="intake", + ), + path( + "workspaces//projects//intakes//", + IntakeViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="intake", + ), + path( + "workspaces//projects//intake-issues/", + IntakeIssueViewSet.as_view({"get": "list", "post": "create"}), + name="intake-issue", + ), + path( + "workspaces//projects//intake-issues//", + IntakeIssueViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="intake-issue", + ), + path( + "workspaces//projects//inboxes/", + IntakeViewSet.as_view({"get": "list", "post": "create"}), + name="inbox", + ), + path( + "workspaces//projects//inboxes//", + IntakeViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="inbox", + ), + path( + "workspaces//projects//inbox-issues/", + IntakeIssueViewSet.as_view({"get": "list", "post": "create"}), + name="inbox-issue", + ), + path( + "workspaces//projects//inbox-issues//", + IntakeIssueViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="inbox-issue", + ), +] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 564725e83..07dfa5112 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -11,7 +11,6 @@ from plane.app.views import ( IssueActivityEndpoint, IssueArchiveViewSet, IssueCommentViewSet, - IssueDraftViewSet, IssueListEndpoint, IssueReactionViewSet, IssueRelationViewSet, @@ -22,6 +21,9 @@ from plane.app.views import ( BulkArchiveIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, + IssueDetailEndpoint, + IssueAttachmentV2Endpoint, + IssueBulkUpdateDateEndpoint, ) urlpatterns = [ @@ -32,14 +34,15 @@ urlpatterns = [ ), path( "workspaces//projects//issues/", - IssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueViewSet.as_view({"get": "list", "post": "create"}), name="project-issue", ), + path( + "workspaces//projects//issues-detail/", + IssueDetailEndpoint.as_view(), + name="project-issue-detail", + ), + # updated v1 paginated issues # updated v2 paginated issues path( "workspaces//projects//v2/issues/", @@ -60,12 +63,7 @@ urlpatterns = [ ), path( "workspaces//projects//issue-labels/", - LabelViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + LabelViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-labels", ), path( @@ -103,12 +101,7 @@ urlpatterns = [ ), path( "workspaces//projects//issues//issue-links/", - IssueLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueLinkViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-links", ), path( @@ -133,6 +126,18 @@ urlpatterns = [ IssueAttachmentEndpoint.as_view(), name="project-issue-attachments", ), + # V2 Attachments + path( + "assets/v2/workspaces//projects//issues//attachments/", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + ## Export Issues path( "workspaces//export-issues/", ExportIssuesEndpoint.as_view(), @@ -149,12 +154,7 @@ urlpatterns = [ ## IssueComments path( "workspaces//projects//issues//comments/", - IssueCommentViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueCommentViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-comment", ), path( @@ -173,12 +173,7 @@ urlpatterns = [ # Issue Subscribers path( "workspaces//projects//issues//issue-subscribers/", - IssueSubscriberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueSubscriberViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-subscribers", ), path( @@ -189,11 +184,7 @@ urlpatterns = [ path( "workspaces//projects//issues//subscribe/", IssueSubscriberViewSet.as_view( - { - "get": "subscription_status", - "post": "subscribe", - "delete": "unsubscribe", - } + {"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"} ), name="project-issue-subscribers", ), @@ -201,42 +192,24 @@ urlpatterns = [ # Issue Reactions path( "workspaces//projects//issues//reactions/", - IssueReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueReactionViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-reactions", ), path( "workspaces//projects//issues//reactions//", - IssueReactionViewSet.as_view( - { - "delete": "destroy", - } - ), + IssueReactionViewSet.as_view({"delete": "destroy"}), name="project-issue-reactions", ), ## End Issue Reactions # Comment Reactions path( "workspaces//projects//comments//reactions/", - CommentReactionViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + CommentReactionViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-comment-reactions", ), path( "workspaces//projects//comments//reactions//", - CommentReactionViewSet.as_view( - { - "delete": "destroy", - } - ), + CommentReactionViewSet.as_view({"delete": "destroy"}), name="project-issue-comment-reactions", ), ## End Comment Reactions @@ -250,21 +223,13 @@ urlpatterns = [ ## Issue Archives path( "workspaces//projects//archived-issues/", - IssueArchiveViewSet.as_view( - { - "get": "list", - } - ), + IssueArchiveViewSet.as_view({"get": "list"}), name="project-issue-archive", ), path( "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( - { - "get": "retrieve", - "post": "archive", - "delete": "unarchive", - } + {"get": "retrieve", "post": "archive", "delete": "unarchive"} ), name="project-issue-archive-unarchive", ), @@ -272,49 +237,23 @@ urlpatterns = [ ## Issue Relation path( "workspaces//projects//issues//issue-relation/", - IssueRelationViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueRelationViewSet.as_view({"get": "list", "post": "create"}), name="issue-relation", ), path( "workspaces//projects//issues//remove-relation/", - IssueRelationViewSet.as_view( - { - "post": "remove_relation", - } - ), + IssueRelationViewSet.as_view({"post": "remove_relation"}), name="issue-relation", ), ## End Issue Relation - ## Issue Drafts - path( - "workspaces//projects//issue-drafts/", - IssueDraftViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-draft", - ), - path( - "workspaces//projects//issue-drafts//", - IssueDraftViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-draft", - ), path( "workspaces//projects//deleted-issues/", DeletedIssuesListViewSet.as_view(), name="deleted-issues", ), + path( + "workspaces//projects//issue-dates/", + IssueBulkUpdateDateEndpoint.as_view(), + name="project-issue-dates", + ), ] diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index bf6c84b2f..75cbb14d6 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -14,12 +14,7 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//projects//modules/", - ModuleViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ModuleViewSet.as_view({"get": "list", "post": "create"}), name="project-modules", ), path( @@ -36,21 +31,12 @@ urlpatterns = [ ), path( "workspaces//projects//issues//modules/", - ModuleIssueViewSet.as_view( - { - "post": "create_issue_modules", - } - ), + ModuleIssueViewSet.as_view({"post": "create_issue_modules"}), name="issue-module", ), path( "workspaces//projects//modules//issues/", - ModuleIssueViewSet.as_view( - { - "post": "create_module_issues", - "get": "list", - } - ), + ModuleIssueViewSet.as_view({"post": "create_module_issues", "get": "list"}), name="project-module-issues", ), path( @@ -67,12 +53,7 @@ urlpatterns = [ ), path( "workspaces//projects//modules//module-links/", - ModuleLinkViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ModuleLinkViewSet.as_view({"get": "list", "post": "create"}), name="project-issue-module-links", ), path( @@ -89,21 +70,12 @@ urlpatterns = [ ), path( "workspaces//projects//user-favorite-modules/", - ModuleFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ModuleFavoriteViewSet.as_view({"get": "list", "post": "create"}), name="user-favorite-module", ), path( "workspaces//projects//user-favorite-modules//", - ModuleFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), + ModuleFavoriteViewSet.as_view({"delete": "destroy"}), name="user-favorite-module", ), path( diff --git a/apiserver/plane/app/urls/notification.py b/apiserver/plane/app/urls/notification.py index 0bbf4f3c7..cd5647ea4 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -12,42 +12,24 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//users/notifications/", - NotificationViewSet.as_view( - { - "get": "list", - } - ), + NotificationViewSet.as_view({"get": "list"}), name="notifications", ), path( "workspaces//users/notifications//", NotificationViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="notifications", ), path( "workspaces//users/notifications//read/", - NotificationViewSet.as_view( - { - "post": "mark_read", - "delete": "mark_unread", - } - ), + NotificationViewSet.as_view({"post": "mark_read", "delete": "mark_unread"}), name="notifications", ), path( "workspaces//users/notifications//archive/", - NotificationViewSet.as_view( - { - "post": "archive", - "delete": "unarchive", - } - ), + NotificationViewSet.as_view({"post": "archive", "delete": "unarchive"}), name="notifications", ), path( @@ -57,11 +39,7 @@ urlpatterns = [ ), path( "workspaces//users/notifications/mark-all-read/", - MarkAllReadNotificationViewSet.as_view( - { - "post": "create", - } - ), + MarkAllReadNotificationViewSet.as_view({"post": "create"}), name="mark-all-read-notifications", ), path( diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 7c1ac5dfe..b49f1d4a2 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -14,66 +14,38 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + PageViewSet.as_view({"get": "list", "post": "create"}), name="project-pages", ), path( "workspaces//projects//pages//", PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="project-pages", ), # favorite pages path( "workspaces//projects//favorite-pages//", - PageFavoriteViewSet.as_view( - { - "post": "create", - "delete": "destroy", - } - ), + PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}), name="user-favorite-pages", ), # archived pages path( "workspaces//projects//pages//archive/", - PageViewSet.as_view( - { - "post": "archive", - "delete": "unarchive", - } - ), + PageViewSet.as_view({"post": "archive", "delete": "unarchive"}), name="project-page-archive-unarchive", ), # lock and unlock path( "workspaces//projects//pages//lock/", - PageViewSet.as_view( - { - "post": "lock", - "delete": "unlock", - } - ), + PageViewSet.as_view({"post": "lock", "delete": "unlock"}), name="project-pages-lock-unlock", ), # private and public page path( "workspaces//projects//pages//access/", - PageViewSet.as_view( - { - "post": "access", - } - ), + PageViewSet.as_view({"post": "access"}), name="project-pages-access", ), path( @@ -93,12 +65,7 @@ urlpatterns = [ ), path( "workspaces//projects//pages//description/", - PagesDescriptionViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - } - ), + PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), name="page-description", ), path( diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 0807c7616..4037402ab 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -7,7 +7,6 @@ from plane.app.views import ( ProjectMemberViewSet, ProjectMemberUserEndpoint, ProjectJoinEndpoint, - AddTeamToProjectEndpoint, ProjectUserViewsEndpoint, ProjectIdentifierEndpoint, ProjectFavoritesViewSet, @@ -21,12 +20,7 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//projects/", - ProjectViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ProjectViewSet.as_view({"get": "list", "post": "create"}), name="project", ), path( @@ -48,32 +42,17 @@ urlpatterns = [ ), path( "workspaces//projects//invitations/", - ProjectInvitationsViewset.as_view( - { - "get": "list", - "post": "create", - }, - ), + ProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), name="project-member-invite", ), path( "workspaces//projects//invitations//", - ProjectInvitationsViewset.as_view( - { - "get": "retrieve", - "delete": "destroy", - } - ), + ProjectInvitationsViewset.as_view({"get": "retrieve", "delete": "destroy"}), name="project-member-invite", ), path( "users/me/workspaces//projects/invitations/", - UserProjectInvitationsViewset.as_view( - { - "get": "list", - "post": "create", - }, - ), + UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), name="user-project-invitations", ), path( @@ -88,39 +67,21 @@ urlpatterns = [ ), path( "workspaces//projects//members/", - ProjectMemberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), name="project-member", ), path( "workspaces//projects//members//", ProjectMemberViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="project-member", ), path( "workspaces//projects//members/leave/", - ProjectMemberViewSet.as_view( - { - "post": "leave", - } - ), + ProjectMemberViewSet.as_view({"post": "leave"}), name="project-member", ), - path( - "workspaces//projects//team-invite/", - AddTeamToProjectEndpoint.as_view(), - name="projects", - ), path( "workspaces//projects//project-views/", ProjectUserViewsEndpoint.as_view(), @@ -133,21 +94,12 @@ urlpatterns = [ ), path( "workspaces//user-favorite-projects/", - ProjectFavoritesViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + ProjectFavoritesViewSet.as_view({"get": "list", "post": "create"}), name="project-favorite", ), path( "workspaces//user-favorite-projects//", - ProjectFavoritesViewSet.as_view( - { - "delete": "destroy", - } - ), + ProjectFavoritesViewSet.as_view({"delete": "destroy"}), name="project-favorite", ), path( @@ -157,22 +109,13 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards/", - DeployBoardViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + DeployBoardViewSet.as_view({"get": "list", "post": "create"}), name="project-deploy-board", ), path( "workspaces//projects//project-deploy-boards//", DeployBoardViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="project-deploy-board", ), diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index 05a79994e..bbea8093d 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -1,10 +1,7 @@ from django.urls import path -from plane.app.views import ( - GlobalSearchEndpoint, - IssueSearchEndpoint, -) +from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint urlpatterns = [ diff --git a/apiserver/plane/app/urls/state.py b/apiserver/plane/app/urls/state.py index 9fec70ea1..b9ffd0341 100644 --- a/apiserver/plane/app/urls/state.py +++ b/apiserver/plane/app/urls/state.py @@ -7,32 +7,19 @@ from plane.app.views import StateViewSet urlpatterns = [ path( "workspaces//projects//states/", - StateViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + StateViewSet.as_view({"get": "list", "post": "create"}), name="project-states", ), path( "workspaces//projects//states//", StateViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="project-state", ), path( "workspaces//projects//states//mark-default/", - StateViewSet.as_view( - { - "post": "mark_as_default", - } - ), + StateViewSet.as_view({"post": "mark_as_default"}), name="project-state", ), ] diff --git a/apiserver/plane/app/urls/user.py b/apiserver/plane/app/urls/user.py index fd18ea87b..443961d0e 100644 --- a/apiserver/plane/app/urls/user.py +++ b/apiserver/plane/app/urls/user.py @@ -22,60 +22,30 @@ urlpatterns = [ path( "users/me/", UserEndpoint.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "deactivate", - } + {"get": "retrieve", "patch": "partial_update", "delete": "deactivate"} ), name="users", ), - path( - "users/session/", - UserSessionEndpoint.as_view(), - name="user-session", - ), + path("users/session/", UserSessionEndpoint.as_view(), name="user-session"), path( "users/me/settings/", - UserEndpoint.as_view( - { - "get": "retrieve_user_settings", - } - ), + UserEndpoint.as_view({"get": "retrieve_user_settings"}), name="users", ), # Profile - path( - "users/me/profile/", - ProfileEndpoint.as_view(), - name="accounts", - ), + path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"), # End profile # Accounts - path( - "users/me/accounts/", - AccountEndpoint.as_view(), - name="accounts", - ), - path( - "users/me/accounts//", - AccountEndpoint.as_view(), - name="accounts", - ), + path("users/me/accounts/", AccountEndpoint.as_view(), name="accounts"), + path("users/me/accounts//", AccountEndpoint.as_view(), name="accounts"), ## End Accounts path( "users/me/instance-admin/", - UserEndpoint.as_view( - { - "get": "retrieve_instance_admin", - } - ), + UserEndpoint.as_view({"get": "retrieve_instance_admin"}), name="users", ), path( - "users/me/onboard/", - UpdateUserOnBoardedEndpoint.as_view(), - name="user-onboard", + "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard" ), path( "users/me/tour-completed/", @@ -83,15 +53,11 @@ urlpatterns = [ name="user-tour", ), path( - "users/me/activities/", - UserActivityEndpoint.as_view(), - name="user-activities", + "users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities" ), # user workspaces path( - "users/me/workspaces/", - UserWorkSpacesEndpoint.as_view(), - name="user-workspace", + "users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace" ), # User Graphs path( diff --git a/apiserver/plane/app/urls/views.py b/apiserver/plane/app/urls/views.py index a2f8e2ac8..063e39c3d 100644 --- a/apiserver/plane/app/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -12,12 +12,7 @@ from plane.app.views import ( urlpatterns = [ path( "workspaces//projects//views/", - IssueViewViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueViewViewSet.as_view({"get": "list", "post": "create"}), name="project-view", ), path( @@ -34,12 +29,7 @@ urlpatterns = [ ), path( "workspaces//views/", - WorkspaceViewViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + WorkspaceViewViewSet.as_view({"get": "list", "post": "create"}), name="global-view", ), path( @@ -56,30 +46,17 @@ urlpatterns = [ ), path( "workspaces//issues/", - WorkspaceViewIssuesViewSet.as_view( - { - "get": "list", - } - ), + WorkspaceViewIssuesViewSet.as_view({"get": "list"}), name="global-view-issues", ), path( "workspaces//projects//user-favorite-views/", - IssueViewFavoriteViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueViewFavoriteViewSet.as_view({"get": "list", "post": "create"}), name="user-favorite-view", ), path( "workspaces//projects//user-favorite-views//", - IssueViewFavoriteViewSet.as_view( - { - "delete": "destroy", - } - ), + IssueViewFavoriteViewSet.as_view({"delete": "destroy"}), name="user-favorite-view", ), ] diff --git a/apiserver/plane/app/urls/webhook.py b/apiserver/plane/app/urls/webhook.py index 16cc48be8..e21ec7261 100644 --- a/apiserver/plane/app/urls/webhook.py +++ b/apiserver/plane/app/urls/webhook.py @@ -8,11 +8,7 @@ from plane.app.views import ( urlpatterns = [ - path( - "workspaces//webhooks/", - WebhookEndpoint.as_view(), - name="webhooks", - ), + path("workspaces//webhooks/", WebhookEndpoint.as_view(), name="webhooks"), path( "workspaces//webhooks//", WebhookEndpoint.as_view(), diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 3f1e000e4..26e623864 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -10,7 +10,6 @@ from plane.app.views import ( WorkspaceMemberUserEndpoint, WorkspaceMemberUserViewsEndpoint, WorkSpaceAvailabilityCheckEndpoint, - TeamMemberViewSet, UserLastProjectWithWorkspaceEndpoint, WorkspaceThemeViewSet, WorkspaceUserProfileStatsEndpoint, @@ -27,6 +26,7 @@ from plane.app.views import ( WorkspaceCyclesEndpoint, WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, + WorkspaceDraftIssueViewSet, ) @@ -38,12 +38,7 @@ urlpatterns = [ ), path( "workspaces/", - WorkSpaceViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + WorkSpaceViewSet.as_view({"get": "list", "post": "create"}), name="workspace", ), path( @@ -60,22 +55,13 @@ urlpatterns = [ ), path( "workspaces//invitations/", - WorkspaceInvitationsViewset.as_view( - { - "get": "list", - "post": "create", - }, - ), + WorkspaceInvitationsViewset.as_view({"get": "list", "post": "create"}), name="workspace-invitations", ), path( "workspaces//invitations//", WorkspaceInvitationsViewset.as_view( - { - "delete": "destroy", - "get": "retrieve", - "patch": "partial_update", - } + {"delete": "destroy", "get": "retrieve", "patch": "partial_update"} ), name="workspace-invitations", ), @@ -83,10 +69,7 @@ urlpatterns = [ path( "users/me/workspaces/invitations/", UserWorkspaceInvitationsViewSet.as_view( - { - "get": "list", - "post": "create", - }, + {"get": "list", "post": "create"} ), name="user-workspace-invitations", ), @@ -109,45 +92,15 @@ urlpatterns = [ path( "workspaces//members//", WorkSpaceMemberViewSet.as_view( - { - "patch": "partial_update", - "delete": "destroy", - "get": "retrieve", - } + {"patch": "partial_update", "delete": "destroy", "get": "retrieve"} ), name="workspace-member", ), path( "workspaces//members/leave/", - WorkSpaceMemberViewSet.as_view( - { - "post": "leave", - }, - ), + WorkSpaceMemberViewSet.as_view({"post": "leave"}), name="leave-workspace-members", ), - path( - "workspaces//teams/", - TeamMemberViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="workspace-team-members", - ), - path( - "workspaces//teams//", - TeamMemberViewSet.as_view( - { - "put": "update", - "patch": "partial_update", - "delete": "destroy", - "get": "retrieve", - } - ), - name="workspace-team-members", - ), path( "users/last-visited-workspace/", UserLastProjectWithWorkspaceEndpoint.as_view(), @@ -165,22 +118,13 @@ urlpatterns = [ ), path( "workspaces//workspace-themes/", - WorkspaceThemeViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + WorkspaceThemeViewSet.as_view({"get": "list", "post": "create"}), name="workspace-themes", ), path( "workspaces//workspace-themes//", WorkspaceThemeViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="workspace-themes", ), @@ -254,4 +198,21 @@ urlpatterns = [ WorkspaceFavoriteGroupEndpoint.as_view(), name="workspace-user-favorites-groups", ), + path( + "workspaces//draft-issues/", + WorkspaceDraftIssueViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-draft-issues", + ), + path( + "workspaces//draft-issues//", + WorkspaceDraftIssueViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="workspace-drafts-issues", + ), + path( + "workspaces//draft-to-issue//", + WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), + name="workspace-drafts-issues", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 6c4cc12c8..581a1065d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -16,7 +16,6 @@ from .project.invite import ( from .project.member import ( ProjectMemberViewSet, - AddTeamToProjectEndpoint, ProjectMemberUserEndpoint, UserProjectRolesEndpoint, ) @@ -40,6 +39,8 @@ from .workspace.base import ( ExportWorkspaceUserActivityEndpoint, ) +from .workspace.draft import WorkspaceDraftIssueViewSet + from .workspace.favorite import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, @@ -47,7 +48,6 @@ from .workspace.favorite import ( from .workspace.member import ( WorkSpaceMemberViewSet, - TeamMemberViewSet, WorkspaceMemberUserEndpoint, WorkspaceProjectMemberEndpoint, WorkspaceMemberUserViewsEndpoint, @@ -57,12 +57,8 @@ from .workspace.invite import ( WorkspaceJoinEndpoint, UserWorkspaceInvitationsViewSet, ) -from .workspace.label import ( - WorkspaceLabelsEndpoint, -) -from .workspace.state import ( - WorkspaceStatesEndpoint, -) +from .workspace.label import WorkspaceLabelsEndpoint +from .workspace.state import WorkspaceStatesEndpoint from .workspace.user import ( UserLastProjectWithWorkspaceEndpoint, WorkspaceUserProfileIssuesEndpoint, @@ -73,15 +69,9 @@ from .workspace.user import ( UserActivityGraphEndpoint, UserIssueCompletedGraphEndpoint, ) -from .workspace.estimate import ( - WorkspaceEstimatesEndpoint, -) -from .workspace.module import ( - WorkspaceModulesEndpoint, -) -from .workspace.cycle import ( - WorkspaceCyclesEndpoint, -) +from .workspace.estimate import WorkspaceEstimatesEndpoint +from .workspace.module import WorkspaceModulesEndpoint +from .workspace.cycle import WorkspaceCyclesEndpoint from .state.base import StateViewSet from .view.base import ( @@ -96,19 +86,21 @@ from .cycle.base import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, - CycleViewSet, - TransferCycleIssueEndpoint, CycleAnalyticsEndpoint, CycleProgressEndpoint, ) -from .cycle.issue import ( - CycleIssueViewSet, -) -from .cycle.archive import ( - CycleArchiveUnarchiveEndpoint, -) +from .cycle.issue import CycleIssueViewSet +from .cycle.archive import CycleArchiveUnarchiveEndpoint from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet +from .asset.v2 import ( + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, +) from .issue.base import ( IssueListEndpoint, IssueViewSet, @@ -116,49 +108,33 @@ from .issue.base import ( BulkDeleteIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, + IssueDetailEndpoint, + IssueBulkUpdateDateEndpoint, ) -from .issue.activity import ( - IssueActivityEndpoint, -) +from .issue.activity import IssueActivityEndpoint from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, + # V2 + IssueAttachmentV2Endpoint, ) -from .issue.comment import ( - IssueCommentViewSet, - CommentReactionViewSet, -) +from .issue.comment import IssueCommentViewSet, CommentReactionViewSet -from .issue.draft import IssueDraftViewSet +from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint -from .issue.label import ( - LabelViewSet, - BulkCreateIssueLabelsEndpoint, -) +from .issue.link import IssueLinkViewSet -from .issue.link import ( - IssueLinkViewSet, -) +from .issue.relation import IssueRelationViewSet -from .issue.relation import ( - IssueRelationViewSet, -) +from .issue.reaction import IssueReactionViewSet -from .issue.reaction import ( - IssueReactionViewSet, -) +from .issue.sub_issue import SubIssuesEndpoint -from .issue.sub_issue import ( - SubIssuesEndpoint, -) - -from .issue.subscriber import ( - IssueSubscriberViewSet, -) +from .issue.subscriber import IssueSubscriberViewSet from .module.base import ( ModuleViewSet, @@ -167,18 +143,11 @@ from .module.base import ( ModuleUserPropertiesEndpoint, ) -from .module.issue import ( - ModuleIssueViewSet, -) +from .module.issue import ModuleIssueViewSet -from .module.archive import ( - ModuleArchiveUnarchiveEndpoint, -) +from .module.archive import ModuleArchiveUnarchiveEndpoint -from .api import ( - ApiTokenEndpoint, - ServiceApiTokenEndpoint, -) +from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint from .page.base import ( PageViewSet, @@ -204,7 +173,7 @@ from .estimate.base import ( EstimatePointEndpoint, ) -from .inbox.base import InboxViewSet, InboxIssueViewSet +from .intake.base import IntakeViewSet, IntakeIssueViewSet from .analytic.base import ( AnalyticsEndpoint, @@ -233,6 +202,5 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint from .error_404 import custom_404_view -from .exporter.base import ExportIssuesEndpoint from .notification.base import MarkAllReadNotificationViewSet from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 65ba1469c..a36458406 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -1,7 +1,10 @@ # Django imports -from django.db.models import Count, F, Sum +from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth from django.utils import timezone +from django.db.models.functions import Concat +from django.db.models import Case, When, Value +from django.db import models # Third party imports from rest_framework import status @@ -19,14 +22,7 @@ from plane.app.permissions import allow_permission, ROLE class AnalyticsEndpoint(BaseAPIView): - - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ], - level="WORKSPACE", - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): x_axis = request.GET.get("x_axis", False) y_axis = request.GET.get("y_axis", False) @@ -47,10 +43,7 @@ class AnalyticsEndpoint(BaseAPIView): "completed_at", ] - valid_yaxis = [ - "issue_count", - "estimate", - ] + valid_yaxis = ["issue_count", "estimate"] # Check for x-axis and y-axis as thery are required parameters if ( @@ -67,9 +60,7 @@ class AnalyticsEndpoint(BaseAPIView): ) # If segment is present it cannot be same as x-axis - if segment and ( - segment not in valid_xaxis_segment or x_axis == segment - ): + if segment and (segment not in valid_xaxis_segment or x_axis == segment): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -94,10 +85,7 @@ class AnalyticsEndpoint(BaseAPIView): state_details = {} if x_axis in ["state_id"] or segment in ["state_id"]: state_details = ( - Issue.issue_objects.filter( - workspace__slug=slug, - **filters, - ) + Issue.issue_objects.filter(workspace__slug=slug, **filters) .distinct("state_id") .order_by("state_id") .values("state_id", "state__name", "state__color") @@ -107,7 +95,10 @@ class AnalyticsEndpoint(BaseAPIView): if x_axis in ["labels__id"] or segment in ["labels__id"]: label_details = ( Issue.objects.filter( - workspace__slug=slug, **filters, labels__id__isnull=False + workspace__slug=slug, + **filters, + labels__id__isnull=False, + label_issue__deleted_at__isnull=True, ) .distinct("labels__id") .order_by("labels__id") @@ -118,14 +109,37 @@ class AnalyticsEndpoint(BaseAPIView): if x_axis in ["assignees__id"] or segment in ["assignees__id"]: assignee_details = ( Issue.issue_objects.filter( + Q( + Q(assignees__avatar__isnull=False) + | Q(assignees__avatar_asset__isnull=False) + ), workspace__slug=slug, **filters, - assignees__avatar__isnull=False, + ) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) ) .order_by("assignees__id") .distinct("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", @@ -134,21 +148,17 @@ class AnalyticsEndpoint(BaseAPIView): ) cycle_details = {} - if x_axis in ["issue_cycle__cycle_id"] or segment in [ - "issue_cycle__cycle_id" - ]: + if x_axis in ["issue_cycle__cycle_id"] or segment in ["issue_cycle__cycle_id"]: cycle_details = ( Issue.issue_objects.filter( workspace__slug=slug, **filters, issue_cycle__cycle_id__isnull=False, + issue_cycle__deleted_at__isnull=True, ) .distinct("issue_cycle__cycle_id") .order_by("issue_cycle__cycle_id") - .values( - "issue_cycle__cycle_id", - "issue_cycle__cycle__name", - ) + .values("issue_cycle__cycle_id", "issue_cycle__cycle__name") ) module_details = {} @@ -160,13 +170,11 @@ class AnalyticsEndpoint(BaseAPIView): workspace__slug=slug, **filters, issue_module__module_id__isnull=False, + issue_module__deleted_at__isnull=True, ) .distinct("issue_module__module_id") .order_by("issue_module__module_id") - .values( - "issue_module__module_id", - "issue_module__module__name", - ) + .values("issue_module__module_id", "issue_module__module__name") ) return Response( @@ -186,9 +194,7 @@ class AnalyticsEndpoint(BaseAPIView): class AnalyticViewViewset(BaseViewSet): - permission_classes = [ - WorkSpaceAdminPermission, - ] + permission_classes = [WorkSpaceAdminPermission] model = AnalyticView serializer_class = AnalyticViewSerializer @@ -198,25 +204,14 @@ class AnalyticViewViewset(BaseViewSet): def get_queryset(self): return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) ) class SavedAnalyticEndpoint(BaseAPIView): - - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ], - level="WORKSPACE", - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, analytic_id): - analytic_view = AnalyticView.objects.get( - pk=analytic_id, workspace__slug=slug - ) + analytic_view = AnalyticView.objects.get(pk=analytic_id, workspace__slug=slug) filter = analytic_view.query queryset = Issue.issue_objects.filter(**filter) @@ -242,14 +237,7 @@ class SavedAnalyticEndpoint(BaseAPIView): class ExportAnalyticsEndpoint(BaseAPIView): - - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ], - level="WORKSPACE", - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): x_axis = request.data.get("x_axis", False) y_axis = request.data.get("y_axis", False) @@ -270,10 +258,7 @@ class ExportAnalyticsEndpoint(BaseAPIView): "completed_at", ] - valid_yaxis = [ - "issue_count", - "estimate", - ] + valid_yaxis = ["issue_count", "estimate"] # Check for x-axis and y-axis as thery are required parameters if ( @@ -290,9 +275,7 @@ class ExportAnalyticsEndpoint(BaseAPIView): ) # If segment is present it cannot be same as x-axis - if segment and ( - segment not in valid_xaxis_segment or x_axis == segment - ): + if segment and (segment not in valid_xaxis_segment or x_axis == segment): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -313,13 +296,10 @@ class ExportAnalyticsEndpoint(BaseAPIView): class DefaultAnalyticsEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug): filters = issue_filters(request.GET, "GET") - base_issues = Issue.issue_objects.filter( - workspace__slug=slug, **filters - ) + base_issues = Issue.issue_objects.filter(workspace__slug=slug, **filters) total_issues = base_issues.count() @@ -332,9 +312,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ) open_issues_groups = ["backlog", "unstarted", "started"] - open_issues_queryset = state_groups.filter( - state__group__in=open_issues_groups - ) + open_issues_queryset = state_groups.filter(state__group__in=open_issues_groups) open_issues = open_issues_queryset.count() open_issues_classified = ( @@ -355,7 +333,6 @@ class DefaultAnalyticsEndpoint(BaseAPIView): user_details = [ "created_by__first_name", "created_by__last_name", - "created_by__avatar", "created_by__display_name", "created_by__id", ] @@ -364,13 +341,31 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.exclude(created_by=None) .values(*user_details) .annotate(count=Count("id")) + .annotate( + created_by__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + created_by__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "created_by__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + created_by__avatar_asset__isnull=True, then="created_by__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .order_by("-count")[:5] ) user_assignee_details = [ "assignees__first_name", "assignees__last_name", - "assignees__avatar", "assignees__display_name", "assignees__id", ] @@ -379,6 +374,25 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=False) .exclude(assignees=None) .values(*user_assignee_details) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .annotate(count=Count("id")) .order_by("-count")[:5] ) @@ -387,12 +401,29 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=True) .values(*user_assignee_details) .annotate(count=Count("id")) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .order_by("-count") ) - open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[ - "sum" - ] + open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))["sum"] total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"] return Response( diff --git a/apiserver/plane/app/views/api.py b/apiserver/plane/app/views/api.py index fe7259fbb..732d96832 100644 --- a/apiserver/plane/app/views/api.py +++ b/apiserver/plane/app/views/api.py @@ -13,9 +13,7 @@ from plane.app.permissions import WorkspaceOwnerPermission class ApiTokenEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceOwnerPermission, - ] + permission_classes = [WorkspaceOwnerPermission] def post(self, request, slug): label = request.data.get("label", str(uuid4().hex)) @@ -37,10 +35,7 @@ class ApiTokenEndpoint(BaseAPIView): serializer = APITokenSerializer(api_token) # Token will be only visible while creating - return Response( - serializer.data, - status=status.HTTP_201_CREATED, - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) def get(self, request, slug, pk=None): if pk is None: @@ -58,23 +53,14 @@ class ApiTokenEndpoint(BaseAPIView): def delete(self, request, slug, pk): api_token = APIToken.objects.get( - workspace__slug=slug, - user=request.user, - pk=pk, - is_service=False, + workspace__slug=slug, user=request.user, pk=pk, is_service=False ) api_token.delete() return Response(status=status.HTTP_204_NO_CONTENT) def patch(self, request, slug, pk): - api_token = APIToken.objects.get( - workspace__slug=slug, - user=request.user, - pk=pk, - ) - serializer = APITokenSerializer( - api_token, data=request.data, partial=True - ) + api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk) + serializer = APITokenSerializer(api_token, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -82,25 +68,17 @@ class ApiTokenEndpoint(BaseAPIView): class ServiceApiTokenEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceOwnerPermission, - ] + permission_classes = [WorkspaceOwnerPermission] def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) api_token = APIToken.objects.filter( - workspace=workspace, - is_service=True, + workspace=workspace, is_service=True ).first() if api_token: - return Response( - { - "token": str(api_token.token), - }, - status=status.HTTP_200_OK, - ) + return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK) else: # Check the user type user_type = 1 if request.user.is_bot else 0 @@ -114,9 +92,5 @@ class ServiceApiTokenEndpoint(BaseAPIView): is_service=True, ) return Response( - { - "token": str(api_token.token), - }, - status=status.HTTP_201_CREATED, + {"token": str(api_token.token)}, status=status.HTTP_201_CREATED ) - diff --git a/apiserver/plane/app/views/asset/base.py b/apiserver/plane/app/views/asset/base.py index 6de4a4ee7..d30f0bb26 100644 --- a/apiserver/plane/app/views/asset/base.py +++ b/apiserver/plane/app/views/asset/base.py @@ -10,11 +10,7 @@ from plane.app.serializers import FileAssetSerializer class FileAssetEndpoint(BaseAPIView): - parser_classes = ( - MultiPartParser, - FormParser, - JSONParser, - ) + parser_classes = (MultiPartParser, FormParser, JSONParser) """ A viewset for viewing and editing task instances. @@ -28,8 +24,7 @@ class FileAssetEndpoint(BaseAPIView): files, context={"request": request}, many=True ) return Response( - {"data": serializer.data, "status": True}, - status=status.HTTP_200_OK, + {"data": serializer.data, "status": True}, status=status.HTTP_200_OK ) else: return Response( @@ -50,7 +45,7 @@ class FileAssetEndpoint(BaseAPIView): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = True - file_asset.save() + file_asset.save(update_fields=["is_deleted"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -59,7 +54,7 @@ class FileAssetViewSet(BaseViewSet): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = False - file_asset.save() + file_asset.save(update_fields=["is_deleted"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -67,16 +62,11 @@ class UserAssetsEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser) def get(self, request, asset_key): - files = FileAsset.objects.filter( - asset=asset_key, created_by=request.user - ) + files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) if files.exists(): - serializer = FileAssetSerializer( - files, context={"request": request} - ) + serializer = FileAssetSerializer(files, context={"request": request}) return Response( - {"data": serializer.data, "status": True}, - status=status.HTTP_200_OK, + {"data": serializer.data, "status": True}, status=status.HTTP_200_OK ) else: return Response( @@ -92,9 +82,7 @@ class UserAssetsEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, asset_key): - file_asset = FileAsset.objects.get( - asset=asset_key, created_by=request.user - ) + file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) file_asset.is_deleted = True - file_asset.save() + file_asset.save(update_fields=["is_deleted"]) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py new file mode 100644 index 000000000..827c95908 --- /dev/null +++ b/apiserver/plane/app/views/asset/v2.py @@ -0,0 +1,687 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +# Module imports +from ..base import BaseAPIView +from plane.db.models import FileAsset, Workspace, Project, User +from plane.settings.storage import S3Storage +from plane.app.permissions import allow_permission, ROLE +from plane.utils.cache import invalidate_cache_directly +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata + + +class UserAssetsV2Endpoint(BaseAPIView): + """This endpoint is used to upload user profile images.""" + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + if asset is None: + return + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save(update_fields=["is_deleted", "deleted_at"]) + return + + def entity_asset_save(self, asset_id, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar = "" + # Delete the previous avatar + if user.avatar_asset_id: + self.asset_delete(user.avatar_asset_id) + # Save the new avatar + user.avatar_asset_id = asset_id + user.save() + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image = None + # Delete the previous cover image + if user.cover_image_asset_id: + self.asset_delete(user.cover_image_asset_id) + # Save the new cover image + user.cover_image_asset_id = asset_id + user.save() + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + return + + def entity_asset_delete(self, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar_asset_id = None + user.save() + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image_asset_id = None + user.save() + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + return + + def post(self, request): + # get the asset key + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", False) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the entity type is allowed + if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + user=request.user, + created_by=request.user, + entity_type=entity_type, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # get the entity and save the asset id for the request field + self.entity_asset_save( + asset_id=asset_id, + entity_type=asset.entity_type, + asset=asset, + request=request, + ) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["is_uploaded", "attributes"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, asset_id): + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceFileAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_field(self, entity_type, entity_id): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return {"workspace_id": entity_id} + + # Project Cover + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return {"project_id": entity_id} + + # User Avatar and Cover + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return {"user_id": entity_id} + + # Issue Attachment and Description + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return {"issue_id": entity_id} + + # Page Description + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return {"page_id": entity_id} + + # Comment Description + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return {"comment_id": entity_id} + return {} + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + # Check if the asset exists + if asset is None: + return + # Mark the asset as deleted + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save(update_fields=["is_deleted", "deleted_at"]) + return + + def entity_asset_save(self, asset_id, entity_type, asset, request): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.filter(id=asset.workspace_id).first() + if workspace is None: + return + # Delete the previous logo + if workspace.logo_asset_id: + self.asset_delete(workspace.logo_asset_id) + # Save the new logo + workspace.logo = "" + workspace.logo_asset_id = asset_id + workspace.save() + invalidate_cache_directly( + path="/api/workspaces/", url_params=False, user=False, request=request + ) + invalidate_cache_directly( + path="/api/users/me/workspaces/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/instances/", url_params=False, user=False, request=request + ) + return + + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.project_id).first() + if project is None: + return + # Delete the previous cover image + if project.cover_image_asset_id: + self.asset_delete(project.cover_image_asset_id) + # Save the new cover image + project.cover_image = "" + project.cover_image_asset_id = asset_id + project.save() + return + else: + return + + def entity_asset_delete(self, entity_type, asset, request): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.get(id=asset.workspace_id) + if workspace is None: + return + workspace.logo_asset_id = None + workspace.save() + invalidate_cache_directly( + path="/api/workspaces/", url_params=False, user=False, request=request + ) + invalidate_cache_directly( + path="/api/users/me/workspaces/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/instances/", url_params=False, user=False, request=request + ) + return + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.project_id).first() + if project is None: + return + project.cover_image_asset_id = None + project.save() + return + else: + return + + def post(self, request, slug): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type") + entity_identifier = request.data.get("entity_identifier", False) + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + **self.get_entity_id_field( + entity_type=entity_type, entity_id=entity_identifier + ), + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # get the entity and save the asset id for the request field + self.entity_asset_save( + asset_id=asset_id, + entity_type=asset.entity_type, + asset=asset, + request=request, + ) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["is_uploaded", "attributes"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, asset_id): + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url(object_name=asset.asset.name) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class StaticFileAssetEndpoint(BaseAPIView): + """This endpoint is used to get the signed URL for a static asset.""" + + permission_classes = [AllowAny] + + def get(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if asset.entity_type not in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + FileAsset.EntityTypeContext.WORKSPACE_LOGO, + FileAsset.EntityTypeContext.PROJECT_COVER, + ]: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url(object_name=asset.asset.name) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def post(self, request, slug, asset_id): + asset = FileAsset.all_objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = False + asset.deleted_at = None + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_field(self, entity_type, entity_id): + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return {"workspace_id": entity_id} + + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return {"project_id": entity_id} + + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return {"user_id": entity_id} + + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return {"issue_id": entity_id} + + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return {"page_id": entity_id} + + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return {"comment_id": entity_id} + + if entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: + return {"draft_issue_id": entity_id} + return {} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + project_id=project_id, + **self.get_entity_id_field(entity_type, entity_identifier), + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def patch(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get(id=pk) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(pk)) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["is_uploaded", "attributes"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def delete(self, request, slug, project_id, pk): + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url(object_name=asset.asset.name) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class ProjectBulkAssetEndpoint(BaseAPIView): + def save_project_cover(self, asset, project_id): + project = Project.objects.get(id=project_id) + project.cover_image_asset_id = asset.id + project.save() + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, entity_id): + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST + ) + + # get the asset id + assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) + + # Get the first asset + asset = assets.first() + + if not asset: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the asset is uploaded + if asset.entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + assets.update(project_id=project_id) + [self.save_project_cover(asset, project_id) for asset in assets] + + if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: + assets.update(issue_id=entity_id) + + if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + assets.update(comment_id=entity_id) + + if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + assets.update(page_id=entity_id) + + if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: + assets.update(draft_issue_id=entity_id) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 45488b64e..92c374966 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -43,18 +43,11 @@ class TimezoneMixin: class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None - permission_classes = [ - IsAuthenticated, - ] + permission_classes = [IsAuthenticated] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) + filter_backends = (DjangoFilterBackend, SearchFilter) - authentication_classes = [ - BaseSessionAuthentication, - ] + authentication_classes = [BaseSessionAuthentication] filterset_fields = [] @@ -65,9 +58,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): return self.model.objects.all() except Exception as e: log_exception(e) - raise APIException( - "Please check the view", status.HTTP_400_BAD_REQUEST - ) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) def handle_exception(self, exc): """ @@ -146,35 +137,24 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): @property def fields(self): fields = [ - field - for field in self.request.GET.get("fields", "").split(",") - if field + field for field in self.request.GET.get("fields", "").split(",") if field ] return fields if fields else None @property def expand(self): expand = [ - expand - for expand in self.request.GET.get("expand", "").split(",") - if expand + expand for expand in self.request.GET.get("expand", "").split(",") if expand ] return expand if expand else None class BaseAPIView(TimezoneMixin, APIView, BasePaginator): - permission_classes = [ - IsAuthenticated, - ] + permission_classes = [IsAuthenticated] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) + filter_backends = (DjangoFilterBackend, SearchFilter) - authentication_classes = [ - BaseSessionAuthentication, - ] + authentication_classes = [BaseSessionAuthentication] filterset_fields = [] @@ -251,17 +231,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def fields(self): fields = [ - field - for field in self.request.GET.get("fields", "").split(",") - if field + field for field in self.request.GET.get("fields", "").split(",") if field ] return fields if fields else None @property def expand(self): expand = [ - expand - for expand in self.request.GET.get("expand", "").split(",") - if expand + expand for expand in self.request.GET.get("expand", "").split(",") if expand ] return expand if expand else None diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 25ad8a2eb..f58ad9aea 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -1,6 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.db import models from django.db.models import ( Case, CharField, @@ -18,7 +19,7 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone # Third party imports @@ -33,7 +34,6 @@ from .. import BaseAPIView class CycleArchiveUnarchiveEndpoint(BaseAPIView): - def get_queryset(self): favorite_subquery = UserFavorite.objects.filter( user=self.request.user, @@ -47,12 +47,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="backlog", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( - backlog_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("backlog_estimate_point")[:1] ) @@ -61,6 +60,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="unstarted", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( @@ -75,12 +75,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="started", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( - started_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("started_estimate_point")[:1] ) @@ -89,6 +88,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="cancelled", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( @@ -103,6 +103,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="completed", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( @@ -116,12 +117,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( estimate_point__estimate__type="points", issue_cycle__cycle_id=OuterRef("pk"), + issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") .annotate( - total_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) ) .values("total_estimate_points")[:1] ) @@ -139,16 +139,14 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) .prefetch_related( Prefetch( "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), + queryset=Label.objects.only("name", "color", "id").distinct(), ) ) .annotate(is_favorite=Exists(favorite_subquery)) @@ -159,6 +157,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -170,6 +169,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -181,6 +181,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -192,6 +193,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -203,6 +205,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -214,6 +217,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -224,9 +228,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), - When( - start_date__gt=timezone.now(), then=Value("UPCOMING") - ), + When(start_date__gt=timezone.now(), then=Value("UPCOMING")), When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), @@ -241,9 +243,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ArrayAgg( "issue_cycle__issue__assignees__id", distinct=True, - filter=~Q( - issue_cycle__issue__assignees__id__isnull=True - ), + filter=~Q(issue_cycle__issue__assignees__id__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -252,48 +252,42 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): 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()), - ), + Subquery(total_estimate_point), Value(0, output_field=FloatField()) + ) ) .order_by("-is_favorite", "name") .distinct() ) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def get(self, request, slug, project_id, pk=None): if pk is None: queryset = ( @@ -329,9 +323,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): return Response(queryset, status=status.HTTP_200_OK) else: queryset = ( - self.get_queryset() - .filter(archived_at__isnull=False) - .filter(pk=pk) + self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk) ) data = ( self.get_queryset() @@ -341,6 +333,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): project_id=self.kwargs.get("project_id"), parent__isnull=False, issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -395,18 +388,36 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), ) ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -433,6 +444,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -441,9 +453,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -474,43 +484,57 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): } if data["start_date"] and data["end_date"]: - data["estimate_distribution"]["completion_chart"] = ( - burndown_plot( - queryset=queryset, - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=pk, - ) + data["estimate_distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=pk, ) # Assignee Distribution assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(first_name=F("assignees__first_name")) .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .annotate(display_name=F("assignees__display_name")) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -539,6 +563,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -548,12 +573,8 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -593,10 +614,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): cycle_id=pk, ) - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id, cycle_id): @@ -604,7 +622,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, @@ -619,8 +637,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): workspace__slug=slug, ).delete() return Response( - {"archived_at": str(cycle.archived_at)}, - status=status.HTTP_200_OK, + {"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index fc04abe35..61ea9eed4 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -20,7 +20,8 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -81,16 +82,14 @@ class CycleViewSet(BaseViewSet): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) .prefetch_related( Prefetch( "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), + queryset=Label.objects.only("name", "color", "id").distinct(), ) ) .annotate(is_favorite=Exists(favorite_subquery)) @@ -101,6 +100,7 @@ class CycleViewSet(BaseViewSet): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -112,6 +112,7 @@ class CycleViewSet(BaseViewSet): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -122,9 +123,7 @@ class CycleViewSet(BaseViewSet): & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), - When( - start_date__gt=timezone.now(), then=Value("UPCOMING") - ), + When(start_date__gt=timezone.now(), then=Value("UPCOMING")), When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), @@ -139,8 +138,11 @@ class CycleViewSet(BaseViewSet): ArrayAgg( "issue_cycle__issue__assignees__id", distinct=True, - filter=~Q( - issue_cycle__issue__assignees__id__isnull=True + filter=~Q(issue_cycle__issue__assignees__id__isnull=True) + & ( + Q( + issue_cycle__issue__issue_assignee__deleted_at__isnull=True + ) ), ), Value([], output_field=ArrayField(UUIDField())), @@ -161,8 +163,7 @@ class CycleViewSet(BaseViewSet): # Current Cycle if cycle_view == "current": queryset = queryset.filter( - start_date__lte=timezone.now(), - end_date__gte=timezone.now(), + start_date__lte=timezone.now(), end_date__gte=timezone.now() ) data = queryset.values( @@ -187,6 +188,7 @@ class CycleViewSet(BaseViewSet): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) @@ -216,6 +218,7 @@ class CycleViewSet(BaseViewSet): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) return Response(data, status=status.HTTP_200_OK) @@ -231,10 +234,7 @@ class CycleViewSet(BaseViewSet): ): serializer = CycleWriteSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - project_id=project_id, - owned_by=request.user, - ) + serializer.save(project_id=project_id, owned_by=request.user) cycle = ( self.get_queryset() .filter(pk=serializer.data["id"]) @@ -255,6 +255,7 @@ class CycleViewSet(BaseViewSet): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -277,9 +278,7 @@ class CycleViewSet(BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) return Response(cycle, status=status.HTTP_201_CREATED) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( { @@ -306,16 +305,11 @@ class CycleViewSet(BaseViewSet): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` request_data = { - "sort_order": request_data.get( - "sort_order", cycle.sort_order - ) + "sort_order": request_data.get("sort_order", cycle.sort_order) } else: return Response( @@ -325,9 +319,7 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer( - cycle, data=request.data, partial=True - ) + serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -347,6 +339,7 @@ class CycleViewSet(BaseViewSet): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -370,16 +363,9 @@ class CycleViewSet(BaseViewSet): return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def retrieve(self, request, slug, project_id, pk): - queryset = ( - self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) - ) + queryset = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) data = ( self.get_queryset() .filter(pk=pk) @@ -389,6 +375,7 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), parent__isnull=False, issue_cycle__cycle_id=pk, + issue_cycle__deleted_at__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -412,6 +399,7 @@ class CycleViewSet(BaseViewSet): "progress_snapshot", "sub_issues", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -425,8 +413,7 @@ class CycleViewSet(BaseViewSet): if data is None: return Response( - {"error": "Cycle not found"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND ) queryset = queryset.first() @@ -438,16 +425,11 @@ class CycleViewSet(BaseViewSet): user_id=request.user.id, project_id=project_id, ) - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN], creator=True, model=Cycle) def destroy(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if cycle.owned_by_id != request.user.id and not ( ProjectMember.objects.filter( workspace__slug=slug, @@ -463,9 +445,9 @@ class CycleViewSet(BaseViewSet): ) cycle_issues = list( - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk") - ).values_list("issue", flat=True) + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) ) issue_activity.delay( @@ -485,12 +467,9 @@ class CycleViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - # Delete the cycle + # TODO: Soft delete the cycle break the onetoone relationship with cycle issue cycle.delete() - # Delete the cycle issues - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk"), - ).delete() + # Delete the user favorite cycle UserFavorite.objects.filter( user=request.user, @@ -594,6 +573,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -604,6 +584,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -614,6 +596,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -624,6 +608,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -634,6 +620,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -644,6 +632,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -660,18 +650,36 @@ class TransferCycleIssueEndpoint(BaseAPIView): assignee_estimate_data = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), ) ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -699,11 +707,10 @@ class TransferCycleIssueEndpoint(BaseAPIView): { "display_name": item["display_name"], "assignee_id": ( - str(item["assignee_id"]) - if item["assignee_id"] - else None + str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -714,6 +721,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): label_distribution_data = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -722,9 +730,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -761,9 +767,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): { "label_name": item["label_name"], "color": item["color"], - "label_id": ( - str(item["label_id"]) if item["label_id"] else None - ), + "label_id": (str(item["label_id"]) if item["label_id"] else None), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -775,21 +779,36 @@ class TransferCycleIssueEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -820,7 +839,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -832,6 +852,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -841,12 +862,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -876,9 +893,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): { "label_name": item["label_name"], "color": item["color"], - "label_id": ( - str(item["label_id"]) if item["label_id"] else None - ), + "label_id": (str(item["label_id"]) if item["label_id"] else None), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -923,10 +938,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): } current_cycle.save(update_fields=["progress_snapshot"]) - if ( - new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() - ): + if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): return Response( { "error": "The cycle where the issues are transferred is already completed" @@ -989,9 +1001,7 @@ class CycleUserPropertiesEndpoint(BaseAPIView): workspace__slug=slug, ) - cycle_properties.filters = request.data.get( - "filters", cycle_properties.filters - ) + cycle_properties.filters = request.data.get("filters", cycle_properties.filters) cycle_properties.display_filters = request.data.get( "display_filters", cycle_properties.display_filters ) @@ -1022,12 +1032,11 @@ class CycleProgressEndpoint(BaseAPIView): Issue.issue_objects.filter( estimate_point__estimate__type="points", issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) - .annotate( - value_as_float=Cast("estimate_point__value", FloatField()) - ) + .annotate(value_as_float=Cast("estimate_point__value", FloatField())) .aggregate( backlog_estimate_point=Sum( Case( @@ -1065,15 +1074,14 @@ class CycleProgressEndpoint(BaseAPIView): ) ), total_estimate_points=Sum( - "value_as_float", - default=Value(0), - output_field=FloatField(), + "value_as_float", default=Value(0), output_field=FloatField() ), ) ) backlog_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, state__group="backlog", @@ -1081,6 +1089,7 @@ class CycleProgressEndpoint(BaseAPIView): unstarted_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, state__group="unstarted", @@ -1088,6 +1097,7 @@ class CycleProgressEndpoint(BaseAPIView): started_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, state__group="started", @@ -1095,6 +1105,7 @@ class CycleProgressEndpoint(BaseAPIView): cancelled_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, state__group="cancelled", @@ -1102,6 +1113,7 @@ class CycleProgressEndpoint(BaseAPIView): completed_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, state__group="completed", @@ -1109,23 +1121,20 @@ class CycleProgressEndpoint(BaseAPIView): total_issues = Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ).count() return Response( { - "backlog_estimate_points": aggregate_estimates[ - "backlog_estimate_point" - ] + "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0, "unstarted_estimate_points": aggregate_estimates[ "unstarted_estimate_point" ] or 0, - "started_estimate_points": aggregate_estimates[ - "started_estimate_point" - ] + "started_estimate_points": aggregate_estimates["started_estimate_point"] or 0, "cancelled_estimate_points": aggregate_estimates[ "cancelled_estimate_point" @@ -1135,9 +1144,7 @@ class CycleProgressEndpoint(BaseAPIView): "completed_estimate_points" ] or 0, - "total_estimate_points": aggregate_estimates[ - "total_estimate_points" - ], + "total_estimate_points": aggregate_estimates["total_estimate_points"], "backlog_issues": backlog_issues, "total_issues": total_issues, "completed_issues": completed_issues, @@ -1148,16 +1155,14 @@ class CycleProgressEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) -class CycleAnalyticsEndpoint(BaseAPIView): +class CycleAnalyticsEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): analytic_type = request.GET.get("type", "issues") cycle = ( Cycle.objects.filter( - workspace__slug=slug, - project_id=project_id, - id=cycle_id, + workspace__slug=slug, project_id=project_id, id=cycle_id ) .annotate( total_issues=Count( @@ -1166,6 +1171,8 @@ class CycleAnalyticsEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -1193,18 +1200,36 @@ class CycleAnalyticsEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), ) ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1231,6 +1256,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -1239,9 +1265,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -1277,18 +1301,38 @@ class CycleAnalyticsEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, project_id=project_id, workspace__slug=slug, ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( "assignee_id", filter=Q(archived_at__isnull=True, is_draft=False), - ), + ) ) .annotate( completed_issues=Count( @@ -1316,6 +1360,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, project_id=project_id, workspace__slug=slug, ) @@ -1325,8 +1370,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", - filter=Q(archived_at__isnull=True, is_draft=False), + "label_id", filter=Q(archived_at__isnull=True, is_draft=False) ) ) .annotate( diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 211f5a88a..6e131d428 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -3,7 +3,7 @@ import json # Django imports from django.core import serializers -from django.db.models import F, Func, OuterRef, Q +from django.db.models import F, Func, OuterRef, Q, Subquery from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -12,19 +12,12 @@ from django.views.decorators.gzip import gzip_page from rest_framework import status from rest_framework.response import Response + # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - CycleIssueSerializer, -) +from plane.app.serializers import CycleIssueSerializer from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Cycle, - CycleIssue, - Issue, - IssueAttachment, - IssueLink, -) +from plane.db.models import Cycle, CycleIssue, Issue, FileAsset, IssueLink from plane.utils.grouper import ( issue_group_values, issue_on_results, @@ -32,10 +25,7 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.permissions import allow_permission, ROLE @@ -46,19 +36,14 @@ class CycleIssueViewSet(BaseViewSet): webhook_event = "cycle_issue" bulk = True - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] + filterset_fields = ["issue__labels__id", "issue__assignees__id"] def get_queryset(self): return self.filter_queryset( super() .get_queryset() .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("issue_id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -80,29 +65,29 @@ class CycleIssueViewSet(BaseViewSet): ) @method_decorator(gzip_page) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id, cycle_id): order_by_param = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") issue_queryset = ( - Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True + ) .filter(project_id=project_id) .filter(workspace__slug=slug) .filter(**filters) .select_related("workspace", "project", "state", "parent") .prefetch_related( - "assignees", - "labels", - "issue_module__module", - "issue_cycle__cycle", + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" ) .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -110,17 +95,16 @@ class CycleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -132,8 +116,7 @@ class CycleIssueViewSet(BaseViewSet): issue_queryset = issue_queryset.filter(**filters) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -142,9 +125,7 @@ class CycleIssueViewSet(BaseViewSet): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -164,9 +145,7 @@ class CycleIssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -184,10 +163,10 @@ class CycleIssueViewSet(BaseViewSet): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -200,9 +179,7 @@ class CycleIssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -213,10 +190,10 @@ class CycleIssueViewSet(BaseViewSet): ), 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -238,18 +215,14 @@ class CycleIssueViewSet(BaseViewSet): if not issues: return Response( - {"error": "Issues are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): return Response( { "error": "The Cycle has already been completed so no new issues can be added" @@ -259,13 +232,9 @@ class CycleIssueViewSet(BaseViewSet): # Get all CycleIssues already created cycle_issues = list( - CycleIssue.objects.filter( - ~Q(cycle_id=cycle_id), issue_id__in=issues - ) + CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) ) - existing_issues = [ - str(cycle_issue.issue_id) for cycle_issue in cycle_issues - ] + existing_issues = [str(cycle_issue.issue_id) for cycle_issue in cycle_issues] new_issues = list(set(issues) - set(existing_issues)) # New issues to create @@ -304,9 +273,7 @@ class CycleIssueViewSet(BaseViewSet): ) # Update the cycle issues - CycleIssue.objects.bulk_update( - updated_records, ["cycle_id"], batch_size=100 - ) + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 4a760ca3b..8ccce092e 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -36,14 +36,13 @@ from plane.db.models import ( DashboardWidget, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueRelation, Project, - ProjectMember, - User, Widget, WorkspaceMember, + CycleIssue, ) from plane.utils.issue_filters import issue_filters @@ -58,7 +57,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -85,7 +85,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -110,7 +111,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, created_by_id=request.user.id, - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -136,7 +138,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, assignees__in=[request.user], state__group="completed", - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -189,7 +192,13 @@ def dashboard_assigned_issues(self, request, slug): ).select_related("issue"), ) ) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -197,8 +206,9 @@ def dashboard_assigned_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -215,7 +225,10 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -223,8 +236,11 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -232,7 +248,11 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -240,10 +260,7 @@ def dashboard_assigned_issues(self, request, slug): ) if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=5, - is_active=True, + workspace__slug=slug, member=request.user, role=5, is_active=True ).exists(): assigned_issues = assigned_issues.filter(created_by=request.user) @@ -251,10 +268,7 @@ def dashboard_assigned_issues(self, request, slug): priority_order = ["urgent", "high", "medium", "low", "none"] assigned_issues = assigned_issues.annotate( priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], output_field=CharField(), ) ).order_by("priority_order") @@ -280,9 +294,7 @@ def dashboard_assigned_issues(self, request, slug): completed_issues_count = assigned_issues.filter( state__group__in=["completed"] ).count() - completed_issues = assigned_issues.filter( - state__group__in=["completed"] - )[:5] + completed_issues = assigned_issues.filter(state__group__in=["completed"])[:5] return Response( { "issues": IssueSerializer( @@ -352,7 +364,13 @@ def dashboard_created_issues(self, request, slug): .filter(**filters) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -360,8 +378,9 @@ def dashboard_created_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -378,7 +397,10 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -386,8 +408,11 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -395,7 +420,11 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -407,10 +436,7 @@ def dashboard_created_issues(self, request, slug): priority_order = ["urgent", "high", "medium", "low", "none"] created_issues = created_issues.annotate( priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], output_field=CharField(), ) ).order_by("priority_order") @@ -436,9 +462,7 @@ def dashboard_created_issues(self, request, slug): completed_issues_count = created_issues.filter( state__group__in=["completed"] ).count() - completed_issues = created_issues.filter( - state__group__in=["completed"] - )[:5] + completed_issues = created_issues.filter(state__group__in=["completed"])[:5] return Response( { "issues": IssueSerializer(completed_issues, many=True).data, @@ -493,10 +517,7 @@ def dashboard_issues_by_state_groups(self, request, slug): extra_filters = {} if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=5, - is_active=True, + workspace__slug=slug, member=request.user, role=5, is_active=True ).exists(): extra_filters = {"created_by": request.user} @@ -533,10 +554,7 @@ def dashboard_issues_by_priority(self, request, slug): extra_filters = {} if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=5, - is_active=True, + workspace__slug=slug, member=request.user, role=5, is_active=True ).exists(): extra_filters = {"created_by": request.user} @@ -561,8 +579,7 @@ def dashboard_issues_by_priority(self, request, slug): # Prepare output including all groups with their counts output_data = [ - {"priority": group, "count": count} - for group, count in all_groups.items() + {"priority": group, "count": count} for group, count in all_groups.items() ] return Response(output_data, status=status.HTTP_200_OK) @@ -579,8 +596,7 @@ def dashboard_recent_activity(self, request, slug): ).select_related("actor", "workspace", "issue", "project")[:8] return Response( - IssueActivitySerializer(queryset, many=True).data, - status=status.HTTP_200_OK, + IssueActivitySerializer(queryset, many=True).data, status=status.HTTP_200_OK ) @@ -610,22 +626,14 @@ def dashboard_recent_projects(self, request, slug): ).exclude(id__in=unique_project_ids) # Append additional project IDs to the existing list - unique_project_ids.update( - additional_projects.values_list("id", flat=True) - ) + unique_project_ids.update(additional_projects.values_list("id", flat=True)) - return Response( - list(unique_project_ids)[:4], - status=status.HTTP_200_OK, - ) + return Response(list(unique_project_ids)[:4], status=status.HTTP_200_OK) def dashboard_recent_collaborators(self, request, slug): project_members_with_activities = ( - WorkspaceMember.objects.filter( - workspace__slug=slug, - is_active=True, - ) + WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True) .annotate( active_issue_count=Count( Case( @@ -650,10 +658,7 @@ def dashboard_recent_collaborators(self, request, slug): .order_by("-active_issue_count") .distinct() ) - return Response( - (project_members_with_activities), - status=status.HTTP_200_OK, - ) + return Response((project_members_with_activities), status=status.HTTP_200_OK) class DashboardEndpoint(BaseAPIView): @@ -702,14 +707,13 @@ class DashboardEndpoint(BaseAPIView): updated_dashboard_widgets = [] for widget_key in widgets_to_fetch: - widget = Widget.objects.filter( - key=widget_key - ).values_list("id", flat=True) + widget = Widget.objects.filter(key=widget_key).values_list( + "id", flat=True + ) if widget: updated_dashboard_widgets.append( DashboardWidget( - widget_id=widget, - dashboard_id=dashboard.id, + widget_id=widget, dashboard_id=dashboard.id ) ) @@ -776,11 +780,7 @@ class DashboardEndpoint(BaseAPIView): func = WIDGETS_MAPPER.get(widget_key) if func is not None: - response = func( - self, - request=request, - slug=slug, - ) + response = func(self, request=request, slug=slug) if isinstance(response, Response): return response @@ -793,8 +793,7 @@ class DashboardEndpoint(BaseAPIView): class WidgetsEndpoint(BaseAPIView): def patch(self, request, dashboard_id, widget_id): dashboard_widget = DashboardWidget.objects.filter( - widget_id=widget_id, - dashboard_id=dashboard_id, + widget_id=widget_id, dashboard_id=dashboard_id ).first() dashboard_widget.is_visible = request.data.get( "is_visible", dashboard_widget.is_visible @@ -802,10 +801,6 @@ class WidgetsEndpoint(BaseAPIView): dashboard_widget.sort_order = request.data.get( "sort_order", dashboard_widget.sort_order ) - dashboard_widget.filters = request.data.get( - "filters", dashboard_widget.filters - ) + dashboard_widget.filters = request.data.get("filters", dashboard_widget.filters) dashboard_widget.save() - return Response( - {"message": "successfully updated"}, status=status.HTTP_200_OK - ) + return Response({"message": "successfully updated"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/error_404.py b/apiserver/plane/app/views/error_404.py index 3c31474e0..97c3c59f7 100644 --- a/apiserver/plane/app/views/error_404.py +++ b/apiserver/plane/app/views/error_404.py @@ -1,5 +1,6 @@ # views.py from django.http import JsonResponse + def custom_404_view(request, exception=None): return JsonResponse({"error": "Page not found."}, status=404) diff --git a/apiserver/plane/app/views/estimate/base.py b/apiserver/plane/app/views/estimate/base.py index 80943c05e..c0e931ca6 100644 --- a/apiserver/plane/app/views/estimate/base.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -11,11 +11,7 @@ from rest_framework import status # Module imports from ..base import BaseViewSet, BaseAPIView -from plane.app.permissions import ( - ProjectEntityPermission, - allow_permission, - ROLE, -) +from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE from plane.db.models import Project, Estimate, EstimatePoint, Issue from plane.app.serializers import ( EstimateSerializer, @@ -32,13 +28,7 @@ def generate_random_name(length=10): class ProjectEstimatePointEndpoint(BaseAPIView): - - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def get(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) if project.estimate_id is not None: @@ -53,17 +43,13 @@ class ProjectEstimatePointEndpoint(BaseAPIView): class BulkEstimatePointEndpoint(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] model = Estimate serializer_class = EstimateSerializer def list(self, request, slug, project_id): estimates = ( - Estimate.objects.filter( - workspace__slug=slug, project_id=project_id - ) + Estimate.objects.filter(workspace__slug=slug, project_id=project_id) .prefetch_related("points") .select_related("workspace", "project") ) @@ -91,9 +77,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): data=request.data.get("estimate_points"), many=True ) if not serializer.is_valid(): - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) estimate_points = EstimatePoint.objects.bulk_create( [ @@ -121,16 +105,12 @@ class BulkEstimatePointEndpoint(BaseViewSet): pk=estimate_id, workspace__slug=slug, project_id=project_id ) serializer = EstimateReadSerializer(estimate) - return Response( - serializer.data, - status=status.HTTP_200_OK, - ) + return Response(serializer.data, status=status.HTTP_200_OK) @invalidate_cache( path="/api/workspaces/:slug/estimates/", url_params=True, user=False ) def partial_update(self, request, slug, project_id, estimate_id): - if not len(request.data.get("estimate_points", [])): return Response( {"error": "Estimate points are required"}, @@ -140,20 +120,15 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate = Estimate.objects.get(pk=estimate_id) if request.data.get("estimate"): - estimate.name = request.data.get("estimate").get( - "name", estimate.name - ) - estimate.type = request.data.get("estimate").get( - "type", estimate.type - ) + estimate.name = request.data.get("estimate").get("name", estimate.name) + estimate.type = request.data.get("estimate").get("type", estimate.type) estimate.save() estimate_points_data = request.data.get("estimate_points", []) estimate_points = EstimatePoint.objects.filter( pk__in=[ - estimate_point.get("id") - for estimate_point in estimate_points_data + estimate_point.get("id") for estimate_point in estimate_points_data ], workspace__slug=slug, project_id=project_id, @@ -178,16 +153,11 @@ class BulkEstimatePointEndpoint(BaseViewSet): updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( - updated_estimate_points, - ["key", "value"], - batch_size=10, + updated_estimate_points, ["key", "value"], batch_size=10 ) estimate_serializer = EstimateReadSerializer(estimate) - return Response( - estimate_serializer.data, - status=status.HTTP_200_OK, - ) + return Response(estimate_serializer.data, status=status.HTTP_200_OK) @invalidate_cache( path="/api/workspaces/:slug/estimates/", url_params=True, user=False @@ -201,7 +171,6 @@ class BulkEstimatePointEndpoint(BaseViewSet): class EstimatePointEndpoint(BaseViewSet): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id, estimate_id): # TODO: add a key validation if the same key already exists @@ -213,18 +182,13 @@ class EstimatePointEndpoint(BaseViewSet): key = request.data.get("key", 0) value = request.data.get("value", "") estimate_point = EstimatePoint.objects.create( - estimate_id=estimate_id, - project_id=project_id, - key=key, - value=value, + estimate_id=estimate_id, project_id=project_id, key=key, value=value ) serializer = EstimatePointSerializer(estimate_point).data return Response(serializer, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def partial_update( - self, request, slug, project_id, estimate_id, estimate_point_id - ): + def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id): # TODO: add a key validation if the same key already exists estimate_point = EstimatePoint.objects.get( pk=estimate_point_id, @@ -236,21 +200,15 @@ class EstimatePointEndpoint(BaseViewSet): estimate_point, data=request.data, partial=True ) if not serializer.is_valid(): - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def destroy( - self, request, slug, project_id, estimate_id, estimate_point_id - ): + def destroy(self, request, slug, project_id, estimate_id, estimate_point_id): new_estimate_id = request.data.get("new_estimate_id", None) estimate_points = EstimatePoint.objects.filter( - estimate_id=estimate_id, - project_id=project_id, - workspace__slug=slug, + estimate_id=estimate_id, project_id=project_id, workspace__slug=slug ) # update all the issues with the new estimate if new_estimate_id: @@ -265,10 +223,8 @@ class EstimatePointEndpoint(BaseViewSet): requested_data=json.dumps( { "estimate_point": ( - str(new_estimate_id) - if new_estimate_id - else None - ), + str(new_estimate_id) if new_estimate_id else None + ) } ), actor_id=str(request.user.id), @@ -280,7 +236,7 @@ class EstimatePointEndpoint(BaseViewSet): str(issue.estimate_point_id) if issue.estimate_point_id else None - ), + ) } ), epoch=int(timezone.now().timestamp()), @@ -295,11 +251,7 @@ class EstimatePointEndpoint(BaseViewSet): for issue in issues: issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - { - "estimate_point": None, - } - ), + requested_data=json.dumps({"estimate_point": None}), actor_id=str(request.user.id), issue_id=issue.id, project_id=str(project_id), @@ -309,16 +261,14 @@ class EstimatePointEndpoint(BaseViewSet): str(issue.estimate_point_id) if issue.estimate_point_id else None - ), + ) } ), epoch=int(timezone.now().timestamp()), ) # delete the estimate point - old_estimate_point = EstimatePoint.objects.filter( - pk=estimate_point_id - ).first() + old_estimate_point = EstimatePoint.objects.filter(pk=estimate_point_id).first() # rearrange the estimate points updated_estimate_points = [] @@ -328,9 +278,7 @@ class EstimatePointEndpoint(BaseViewSet): updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( - updated_estimate_points, - ["key"], - batch_size=10, + updated_estimate_points, ["key"], batch_size=10 ) old_estimate_point.delete() diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 39255bd1e..8e683e56d 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -51,9 +51,7 @@ class ExportIssuesEndpoint(BaseAPIView): slug=slug, ) return Response( - { - "message": "Once the export is ready you will be able to download it" - }, + {"message": "Once the export is ready you will be able to download it"}, status=status.HTTP_200_OK, ) else: @@ -62,18 +60,13 @@ class ExportIssuesEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): exporter_history = ExporterHistory.objects.filter( - workspace__slug=slug, - type="issue_exports", + workspace__slug=slug, type="issue_exports" ).select_related("workspace", "initiated_by") - if request.GET.get("per_page", False) and request.GET.get( - "cursor", False - ): + 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, diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 6ae3f37ba..1dfbc421a 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -13,15 +13,11 @@ from rest_framework import status from ..base import BaseAPIView from plane.app.permissions import allow_permission, ROLE from plane.db.models import Workspace, Project -from plane.app.serializers import ( - ProjectLiteSerializer, - WorkspaceLiteSerializer, -) +from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.license.utils.instance_value import get_configuration_value class GPTIntegrationEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( @@ -50,19 +46,15 @@ class GPTIntegrationEndpoint(BaseAPIView): if not task: return Response( - {"error": "Task is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) final_text = task + "\n" + prompt - client = OpenAI( - api_key=OPENAI_API_KEY, - ) + client = OpenAI(api_key=OPENAI_API_KEY) response = client.chat.completions.create( - model=GPT_ENGINE, - messages=[{"role": "user", "content": final_text}], + model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] ) workspace = Workspace.objects.get(slug=slug) @@ -82,10 +74,7 @@ class GPTIntegrationEndpoint(BaseAPIView): class WorkspaceGPTIntegrationEndpoint(BaseAPIView): - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( [ @@ -113,29 +102,21 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView): if not task: return Response( - {"error": "Task is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) final_text = task + "\n" + prompt - client = OpenAI( - api_key=OPENAI_API_KEY, - ) + client = OpenAI(api_key=OPENAI_API_KEY) response = client.chat.completions.create( - model=GPT_ENGINE, - messages=[{"role": "user", "content": final_text}], + model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] ) text = response.choices[0].message.content.strip() text_html = text.replace("\n", "
") return Response( - { - "response": text, - "response_html": text_html, - }, - status=status.HTTP_200_OK, + {"response": text, "response_html": text_html}, status=status.HTTP_200_OK ) @@ -164,9 +145,7 @@ class UnsplashEndpoint(BaseAPIView): else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" ) - headers = { - "Content-Type": "application/json", - } + headers = {"Content-Type": "application/json"} resp = requests.get(url=url, headers=headers) return Response(resp.json(), status=resp.status_code) diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/intake/base.py similarity index 66% rename from apiserver/plane/app/views/inbox/base.py rename to apiserver/plane/app/views/intake/base.py index 4a32d9930..8647117c5 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/intake/base.py @@ -3,7 +3,7 @@ import json # Django import from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Subquery from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -18,30 +18,30 @@ from rest_framework.response import Response from ..base import BaseViewSet from plane.app.permissions import allow_permission, ROLE from plane.db.models import ( - Inbox, - InboxIssue, + Intake, + IntakeIssue, Issue, State, IssueLink, - IssueAttachment, + FileAsset, Project, ProjectMember, + CycleIssue, ) from plane.app.serializers import ( IssueCreateSerializer, IssueSerializer, - InboxSerializer, - InboxIssueSerializer, - InboxIssueDetailSerializer, + IntakeSerializer, + IntakeIssueSerializer, + IntakeIssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activities_task import issue_activity -class InboxViewSet(BaseViewSet): - - serializer_class = InboxSerializer - model = Inbox +class IntakeViewSet(BaseViewSet): + serializer_class = IntakeSerializer + model = Intake def get_queryset(self): return ( @@ -53,8 +53,7 @@ class InboxViewSet(BaseViewSet): ) .annotate( pending_issue_count=Count( - "issue_inbox", - filter=Q(issue_inbox__status=-2), + "issue_intake", filter=Q(issue_intake__status=-2) ) ) .select_related("workspace", "project") @@ -62,11 +61,8 @@ class InboxViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id): - inbox = self.get_queryset().first() - return Response( - InboxSerializer(inbox).data, - status=status.HTTP_200_OK, - ) + intake = self.get_queryset().first() + return Response(IntakeSerializer(intake).data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def perform_create(self, serializer): @@ -74,27 +70,24 @@ class InboxViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.filter( + intake = Intake.objects.filter( workspace__slug=slug, project_id=project_id, pk=pk ).first() - # Handle default inbox delete - if inbox.is_default: + # Handle default intake delete + if intake.is_default: return Response( - {"error": "You cannot delete the default inbox"}, + {"error": "You cannot delete the default intake"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox.delete() + intake.delete() return Response(status=status.HTTP_204_NO_CONTENT) -class InboxIssueViewSet(BaseViewSet): +class IntakeIssueViewSet(BaseViewSet): + serializer_class = IntakeIssueSerializer + model = IntakeIssue - serializer_class = InboxIssueSerializer - model = InboxIssue - - filterset_fields = [ - "status", - ] + filterset_fields = ["statulls"] def get_queryset(self): return ( @@ -106,13 +99,19 @@ class InboxIssueViewSet(BaseViewSet): .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( + "issue_intake", + queryset=IntakeIssue.objects.only( "status", "duplicate_to", "snoozed_till", "source" ), ) ) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -120,17 +119,16 @@ class InboxIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -140,7 +138,10 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -148,8 +149,11 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -157,8 +161,11 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -167,38 +174,39 @@ class InboxIssueViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): - inbox_id = Inbox.objects.filter( + intake_id = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() project = Project.objects.get(pk=project_id) filters = issue_filters(request.GET, "GET", "issue__") - inbox_issue = ( - InboxIssue.objects.filter( - inbox_id=inbox_id.id, project_id=project_id, **filters + intake_issue = ( + IntakeIssue.objects.filter( + intake_id=intake_id.id, project_id=project_id, **filters ) .select_related("issue") - .prefetch_related( - "issue__labels", - ) + .prefetch_related("issue__labels") .annotate( label_ids=Coalesce( ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=Q( + ~Q(issue__labels__id__isnull=True) + & Q(issue__label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ) ) ).order_by(request.GET.get("order_by", "-issue__created_at")) - # inbox status filter - inbox_status = [ + # Intake status filter + intake_status = [ item for item in request.GET.get("status", "-2").split(",") if item != "null" ] - if inbox_status: - inbox_issue = inbox_issue.filter(status__in=inbox_status) + if intake_status: + intake_issue = intake_issue.filter(status__in=intake_status) if ( ProjectMember.objects.filter( @@ -210,13 +218,12 @@ class InboxIssueViewSet(BaseViewSet): ).exists() and not project.guest_view_all_features ): - inbox_issue = inbox_issue.filter(created_by=request.user) + intake_issue = intake_issue.filter(created_by=request.user) return self.paginate( request=request, - queryset=(inbox_issue), - on_results=lambda inbox_issues: InboxIssueSerializer( - inbox_issues, - many=True, + queryset=(intake_issue), + on_results=lambda intake_issues: IntakeIssueSerializer( + intake_issues, many=True ).data, ) @@ -224,8 +231,7 @@ class InboxIssueViewSet(BaseViewSet): def create(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) # Check for valid priority @@ -237,20 +243,9 @@ class InboxIssueViewSet(BaseViewSet): "none", ]: return Response( - {"error": "Invalid priority"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="triage", - description="Default state for managing all Inbox Issues", - project_id=project_id, - color="#ff7700", - is_triage=True, - ) - # create an issue project = Project.objects.get(pk=project_id) serializer = IssueCreateSerializer( @@ -263,15 +258,15 @@ class InboxIssueViewSet(BaseViewSet): ) if serializer.is_valid(): serializer.save() - inbox_id = Inbox.objects.filter( + intake_id = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox_id.id, + # create an intake issue + intake_issue = IntakeIssue.objects.create( + intake_id=intake_id.id, project_id=project_id, issue_id=serializer.data["id"], - source=request.data.get("source", "in-app"), + source=request.data.get("source", "IN-APP"), ) # Create an Issue Activity issue_activity.delay( @@ -284,20 +279,20 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), - inbox=str(inbox_issue.id), + intake=str(intake_issue.id), ) - inbox_issue = ( - InboxIssue.objects.select_related("issue") - .prefetch_related( - "issue__labels", - "issue__assignees", - ) + intake_issue = ( + IntakeIssue.objects.select_related("issue") + .prefetch_related("issue__labels", "issue__assignees") .annotate( label_ids=Coalesce( ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=Q( + ~Q(issue__labels__id__isnull=True) + & Q(issue__label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -305,34 +300,33 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__assignees__id", distinct=True, - filter=~Q(issue__assignees__id__isnull=True), + filter=~Q(issue__assignees__id__isnull=True) + & Q(issue__assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), ) .get( - inbox_id=inbox_id.id, + intake_id=intake_id.id, issue_id=serializer.data["id"], project_id=project_id, ) ) - serializer = InboxIssueDetailSerializer(inbox_issue) + serializer = IntakeIssueDetailSerializer(intake_issue) return Response(serializer.data, status=status.HTTP_200_OK) else: - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) def partial_update(self, request, slug, project_id, pk): - inbox_id = Inbox.objects.filter( + intake_id = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - inbox_issue = InboxIssue.objects.get( + intake_issue = IntakeIssue.objects.get( issue_id=pk, workspace__slug=slug, project_id=project_id, - inbox_id=inbox_id, + intake_id=intake_id, ) # Get the project member project_member = ProjectMember.objects.get( @@ -342,11 +336,11 @@ class InboxIssueViewSet(BaseViewSet): is_active=True, ) # Only project members admins and created_by users can access this endpoint - if project_member.role <= 5 and str(inbox_issue.created_by_id) != str( + if project_member.role <= 5 and str(intake_issue.created_by_id) != str( request.user.id ): return Response( - {"error": "You cannot edit inbox issues"}, + {"error": "You cannot edit intake issues"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -358,7 +352,10 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -366,15 +363,14 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), - ).get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) + ).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id) # Only allow guests to edit name and description if project_member.role <= 5: issue_data = { @@ -382,9 +378,7 @@ class InboxIssueViewSet(BaseViewSet): "description_html": issue_data.get( "description_html", issue.description_html ), - "description": issue_data.get( - "description", issue.description - ), + "description": issue_data.get("description", issue.description), } issue_serializer = IssueCreateSerializer( @@ -409,7 +403,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), - inbox=str(inbox_issue.id), + intake=str(intake_issue.id), ) issue_serializer.save() else: @@ -417,27 +411,25 @@ class InboxIssueViewSet(BaseViewSet): issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - # Only project admins and members can edit inbox issue attributes + # Only project admins and members can edit intake issue attributes if project_member.role > 15: - serializer = InboxIssueSerializer( - inbox_issue, data=request.data, partial=True + serializer = IntakeIssueSerializer( + intake_issue, data=request.data, partial=True ) current_instance = json.dumps( - InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder ) if serializer.is_valid(): serializer.save() # Update the issue state if the issue is rejected or marked as duplicate if serializer.data["status"] in [-1, 2]: issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id, ) state = State.objects.filter( - group="cancelled", - workspace__slug=slug, - project_id=project_id, + group="cancelled", workspace__slug=slug, project_id=project_id ).first() if state is not None: issue.state = state @@ -446,7 +438,7 @@ class InboxIssueViewSet(BaseViewSet): # Update the issue state if it is accepted if serializer.data["status"] in [1]: issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id, ) @@ -455,19 +447,15 @@ class InboxIssueViewSet(BaseViewSet): if issue.state.is_triage: # Move to default state state = State.objects.filter( - workspace__slug=slug, - project_id=project_id, - default=True, + workspace__slug=slug, project_id=project_id, default=True ).first() if state is not None: issue.state = state issue.save() # create a activity for status change issue_activity.delay( - type="inbox.activity.created", - requested_data=json.dumps( - request.data, cls=DjangoJSONEncoder - ), + type="intake.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), @@ -475,21 +463,21 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), - inbox=(inbox_issue.id), + intake=(intake_issue.id), ) - inbox_issue = ( - InboxIssue.objects.select_related("issue") - .prefetch_related( - "issue__labels", - "issue__assignees", - ) + intake_issue = ( + IntakeIssue.objects.select_related("issue") + .prefetch_related("issue__labels", "issue__assignees") .annotate( label_ids=Coalesce( ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=Q( + ~Q(issue__labels__id__isnull=True) + & Q(issue__label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -497,52 +485,43 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__assignees__id", distinct=True, - filter=~Q(issue__assignees__id__isnull=True), + filter=Q( + ~Q(issue__assignees__id__isnull=True) + & Q(issue__issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), ) - .get( - inbox_id=inbox_id.id, - issue_id=pk, - project_id=project_id, - ) + .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id) ) - serializer = InboxIssueDetailSerializer(inbox_issue).data + serializer = IntakeIssueDetailSerializer(intake_issue).data return Response(serializer, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: - serializer = InboxIssueDetailSerializer(inbox_issue).data + serializer = IntakeIssueDetailSerializer(intake_issue).data return Response(serializer, status=status.HTTP_200_OK) @allow_permission( - allowed_roles=[ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ], - creator=True, - model=Issue, + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue ) def retrieve(self, request, slug, project_id, pk): - inbox_id = Inbox.objects.filter( + intake_id = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() project = Project.objects.get(pk=project_id) - inbox_issue = ( - InboxIssue.objects.select_related("issue") - .prefetch_related( - "issue__labels", - "issue__assignees", - ) + intake_issue = ( + IntakeIssue.objects.select_related("issue") + .prefetch_related("issue__labels", "issue__assignees") .annotate( label_ids=Coalesce( ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=Q( + ~Q(issue__labels__id__isnull=True) + & Q(issue__label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -550,12 +529,15 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__assignees__id", distinct=True, - filter=~Q(issue__assignees__id__isnull=True), + filter=Q( + ~Q(issue__assignees__id__isnull=True) + & Q(issue__issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), ) - .get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id) + .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id) ) if ( ProjectMember.objects.filter( @@ -566,37 +548,34 @@ class InboxIssueViewSet(BaseViewSet): is_active=True, ).exists() and not project.guest_view_all_features - and not inbox_issue.created_by == request.user + and not intake_issue.created_by == request.user ): return Response( {"error": "You are not allowed to view this issue"}, status=status.HTTP_400_BAD_REQUEST, ) - issue = InboxIssueDetailSerializer(inbox_issue).data - return Response( - issue, - status=status.HTTP_200_OK, - ) + issue = IntakeIssueDetailSerializer(intake_issue).data + return Response(issue, status=status.HTTP_200_OK) @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) def destroy(self, request, slug, project_id, pk): - inbox_id = Inbox.objects.filter( + intake_id = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() - inbox_issue = InboxIssue.objects.get( + intake_issue = IntakeIssue.objects.get( issue_id=pk, workspace__slug=slug, project_id=project_id, - inbox_id=inbox_id, + intake_id=intake_id, ) # Check the issue status - if inbox_issue.status in [-2, -1, 0, 2]: + if intake_issue.status in [-2, -1, 0, 2]: # Delete the issue also issue = Issue.objects.filter( workspace__slug=slug, project_id=project_id, pk=pk ).first() issue.delete() - inbox_issue.delete() + intake_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/activity.py b/apiserver/plane/app/views/issue/activity.py index f46cb87f3..1b6629e47 100644 --- a/apiserver/plane/app/views/issue/activity.py +++ b/apiserver/plane/app/views/issue/activity.py @@ -2,10 +2,7 @@ from itertools import chain # Django imports -from django.db.models import ( - Prefetch, - Q, -) +from django.db.models import Prefetch, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -15,35 +12,16 @@ from rest_framework import status # Module imports from .. import BaseAPIView -from plane.app.serializers import ( - IssueActivitySerializer, - IssueCommentSerializer, -) -from plane.app.permissions import ( - ProjectEntityPermission, - allow_permission, - ROLE, -) -from plane.db.models import ( - IssueActivity, - IssueComment, - CommentReaction, -) +from plane.app.serializers import IssueActivitySerializer, IssueCommentSerializer +from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE +from plane.db.models import IssueActivity, IssueComment, CommentReaction class IssueActivityEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] @method_decorator(gzip_page) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, issue_id): filters = {} if request.GET.get("created_at__gt", None) is not None: @@ -79,9 +57,7 @@ class IssueActivityEndpoint(BaseAPIView): ) ) ) - issue_activities = IssueActivitySerializer( - issue_activities, many=True - ).data + issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data if request.GET.get("activity_type", None) == "issue-property": diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 4817ea90e..4f1e357da 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -3,14 +3,7 @@ import json # Django imports from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import ( - F, - Func, - OuterRef, - Q, - Prefetch, - Exists, -) +from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -19,9 +12,7 @@ from django.views.decorators.gzip import gzip_page from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - ProjectEntityPermission, -) +from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( IssueFlatSerializer, IssueSerializer, @@ -30,10 +21,11 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, IssueReaction, + CycleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -42,10 +34,7 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.permissions import allow_permission, ROLE from plane.utils.error_codes import ERROR_CODES @@ -71,7 +60,13 @@ class IssueArchiveViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -79,17 +74,16 @@ class IssueArchiveViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -97,12 +91,7 @@ class IssueArchiveViewSet(BaseViewSet): ) @method_decorator(gzip_page) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") @@ -118,8 +107,7 @@ class IssueArchiveViewSet(BaseViewSet): ) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -128,9 +116,7 @@ class IssueArchiveViewSet(BaseViewSet): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -150,9 +136,7 @@ class IssueArchiveViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -170,10 +154,10 @@ class IssueArchiveViewSet(BaseViewSet): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -186,9 +170,7 @@ class IssueArchiveViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -199,10 +181,10 @@ class IssueArchiveViewSet(BaseViewSet): ), 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -218,12 +200,7 @@ class IssueArchiveViewSet(BaseViewSet): ), ) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def retrieve(self, request, slug, project_id, pk=None): issue = ( self.get_queryset() @@ -231,15 +208,7 @@ class IssueArchiveViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), + queryset=IssueReaction.objects.select_related("issue", "actor"), ) ) .prefetch_related( @@ -270,24 +239,17 @@ class IssueArchiveViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def archive(self, request, slug, project_id, pk=None): issue = Issue.issue_objects.get( - workspace__slug=slug, - project_id=project_id, - pk=pk, + workspace__slug=slug, project_id=project_id, pk=pk ) if issue.state.group not in ["completed", "cancelled"]: return Response( - { - "error": "Can only archive completed or cancelled state group issue" - }, + {"error": "Can only archive completed or cancelled state group issue"}, 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, - } + {"archived_at": str(timezone.now().date()), "automation": False} ), actor_id=str(request.user.id), issue_id=str(issue.id), @@ -334,9 +296,7 @@ class IssueArchiveViewSet(BaseViewSet): class BulkArchiveIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): @@ -344,8 +304,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): if not len(issue_ids): return Response( - {"error": "Issue IDs are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST ) issues = Issue.objects.filter( @@ -356,9 +315,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): if issue.state.group not in ["completed", "cancelled"]: return Response( { - "error_code": ERROR_CODES[ - "INVALID_ARCHIVE_STATE_GROUP" - ], + "error_code": ERROR_CODES["INVALID_ARCHIVE_STATE_GROUP"], "error_message": "INVALID_ARCHIVE_STATE_GROUP", }, status=status.HTTP_400_BAD_REQUEST, @@ -366,10 +323,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps( - { - "archived_at": str(timezone.now().date()), - "automation": False, - } + {"archived_at": str(timezone.now().date()), "automation": False} ), actor_id=str(request.user.id), issue_id=str(issue.id), @@ -386,6 +340,5 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) return Response( - {"archived_at": str(timezone.now().date())}, - status=status.HTTP_200_OK, + {"archived_at": str(timezone.now().date())}, status=status.HTTP_200_OK ) diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 434c72d1d..4427227f1 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -1,9 +1,12 @@ # Python imports import json +import uuid # Django imports from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder +from django.conf import settings +from django.http import HttpResponseRedirect # Third Party imports from rest_framework.response import Response @@ -13,31 +16,36 @@ from rest_framework.parsers import MultiPartParser, FormParser # Module imports from .. import BaseAPIView from plane.app.serializers import IssueAttachmentSerializer -from plane.db.models import IssueAttachment +from plane.db.models import FileAsset, Workspace from plane.bgtasks.issue_activities_task import issue_activity from plane.app.permissions import allow_permission, ROLE +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer - model = IssueAttachment + model = FileAsset parser_classes = (MultiPartParser, FormParser) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): serializer = IssueAttachmentSerializer(data=request.data) + workspace = Workspace.objects.get(slug=slug) if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) + serializer.save( + project_id=project_id, + issue_id=issue_id, + workspace_id=workspace.id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) issue_activity.delay( type="attachment.activity.created", requested_data=None, actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - serializer.data, - cls=DjangoJSONEncoder, - ), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -45,9 +53,9 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment) + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -64,16 +72,154 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueAttachmentV2Endpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + model = FileAsset + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, issue_id): + name = request.data.get("name") + type = request.data.get("type", False) + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + {"error": "Invalid file type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Get the size limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "attachment": IssueAttachmentSerializer(asset).data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + issue_attachment.is_deleted = True + issue_attachment.deleted_at = timezone.now() + issue_attachment.save() + + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The asset is not uploaded.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + return HttpResponseRedirect(presigned_url) + + # Get all the attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + # Serialize the attachments + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def patch(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachment) + + # Send this activity only if the attachment is not uploaded before + if not issue_attachment.is_uploaded: + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + # Update the attachment + issue_attachment.is_uploaded = True + issue_attachment.created_by = request.user + + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index eca14018f..3d548aeac 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -14,6 +14,9 @@ from django.db.models import ( Q, UUIDField, Value, + Subquery, + Case, + When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -35,13 +38,14 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueUserProperty, IssueReaction, IssueSubscriber, Project, ProjectMember, + CycleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -50,10 +54,7 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from .. import BaseAPIView, BaseViewSet from plane.utils.user_timezone_converter import user_timezone_converter from plane.bgtasks.recent_visited_task import recent_visited_task @@ -68,13 +69,10 @@ class IssueListEndpoint(BaseAPIView): if not issue_ids: return Response( - {"error": "Issues are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) - issue_ids = [ - issue_id for issue_id in issue_ids.split(",") if issue_id != "" - ] + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] queryset = ( Issue.issue_objects.filter( @@ -83,7 +81,13 @@ class IssueListEndpoint(BaseAPIView): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -91,17 +95,16 @@ class IssueListEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -114,8 +117,7 @@ class IssueListEndpoint(BaseAPIView): issue_queryset = queryset.filter(**filters) # Issue queryset issue_queryset, _ = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -124,9 +126,7 @@ class IssueListEndpoint(BaseAPIView): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) recent_visited_task.delay( @@ -188,25 +188,23 @@ class IssueViewSet(BaseViewSet): model = Issue webhook_event = "issue" - search_fields = [ - "name", - ] + search_fields = ["name"] - filterset_fields = [ - "state__name", - "assignees__id", - "workspace__id", - ] + filterset_fields = ["state__name", "assignees__id", "workspace__id"] def get_queryset(self): return ( - Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id") - ) + Issue.issue_objects.filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -214,17 +212,16 @@ class IssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -236,9 +233,7 @@ class IssueViewSet(BaseViewSet): def list(self, request, slug, project_id): extra_filters = {} if request.GET.get("updated_at__gt", None) is not None: - extra_filters = { - "updated_at__gt": request.GET.get("updated_at__gt") - } + extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")} project = Project.objects.get(pk=project_id, workspace__slug=slug) filters = issue_filters(request.query_params, "GET") @@ -249,8 +244,7 @@ class IssueViewSet(BaseViewSet): # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -259,9 +253,7 @@ class IssueViewSet(BaseViewSet): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) recent_visited_task.delay( @@ -298,9 +290,7 @@ class IssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -318,10 +308,10 @@ class IssueViewSet(BaseViewSet): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -333,9 +323,7 @@ class IssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -346,10 +334,10 @@ class IssueViewSet(BaseViewSet): ), 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -383,9 +371,7 @@ class IssueViewSet(BaseViewSet): # Track the issue issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), @@ -396,9 +382,7 @@ class IssueViewSet(BaseViewSet): ) issue = ( issue_queryset_grouper( - queryset=self.get_queryset().filter( - pk=serializer.data["id"] - ), + queryset=self.get_queryset().filter(pk=serializer.data["id"]), group_by=None, sub_group_by=None, ) @@ -450,26 +434,56 @@ class IssueViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @allow_permission( - allowed_roles=[ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ], - creator=True, - model=Issue, + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue ) def retrieve(self, request, slug, project_id, pk=None): project = Project.objects.get(pk=project_id, workspace__slug=slug) issue = ( - self.get_queryset() + Issue.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .filter(pk=pk) .annotate( label_ids=Coalesce( ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -477,8 +491,11 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -486,8 +503,11 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -495,15 +515,7 @@ class IssueViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), + queryset=IssueReaction.objects.select_related("issue", "actor"), ) ) .prefetch_related( @@ -572,7 +584,10 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -580,8 +595,11 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -589,7 +607,11 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -600,8 +622,7 @@ class IssueViewSet(BaseViewSet): if not issue: return Response( - {"error": "Issue not found"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND ) current_instance = json.dumps( @@ -609,9 +630,7 @@ class IssueViewSet(BaseViewSet): ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueCreateSerializer( - issue, data=request.data, partial=True - ) + serializer = IssueCreateSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -639,9 +658,7 @@ class IssueViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN], creator=True, model=Issue) def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) issue.delete() issue_activity.delay( @@ -662,13 +679,10 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id): issue_property = IssueUserProperty.objects.get( - user=request.user, - project_id=project_id, + user=request.user, project_id=project_id ) - issue_property.filters = request.data.get( - "filters", issue_property.filters - ) + issue_property.filters = request.data.get("filters", issue_property.filters) issue_property.display_filters = request.data.get( "display_filters", issue_property.display_filters ) @@ -679,13 +693,7 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView): serializer = IssueUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): issue_property, _ = IssueUserProperty.objects.get_or_create( user=request.user, project_id=project_id @@ -701,8 +709,7 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): if not len(issue_ids): return Response( - {"error": "Issue IDs are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST ) issues = Issue.issue_objects.filter( @@ -726,10 +733,7 @@ class DeletedIssuesListViewSet(BaseAPIView): if request.GET.get("updated_at__gt", None) is not None: filters = {"updated_at__gt": request.GET.get("updated_at__gt")} deleted_issues = ( - Issue.all_objects.filter( - workspace__slug=slug, - project_id=project_id, - ) + Issue.all_objects.filter(workspace__slug=slug, project_id=project_id) .filter(Q(archived_at__isnull=False) | Q(deleted_at__isnull=False)) .filter(**filters) .values_list("id", flat=True) @@ -748,11 +752,15 @@ class IssuePaginatedViewSet(BaseViewSet): ) return ( - issue_queryset.select_related( - "workspace", "project", "state", "parent" - ) + issue_queryset.select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -760,17 +768,16 @@ class IssuePaginatedViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -791,7 +798,7 @@ class IssuePaginatedViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): cursor = request.GET.get("cursor", None) - is_description_required = request.GET.get("description", False) + is_description_required = request.GET.get("description", "false") updated_at = request.GET.get("updated_at__gt", None) # required fields @@ -824,7 +831,7 @@ class IssuePaginatedViewSet(BaseViewSet): "sub_issues_count", ] - if is_description_required: + if str(is_description_required).lower() == "true": required_fields.append("description_html") # querying issues @@ -858,7 +865,10 @@ class IssuePaginatedViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -866,8 +876,11 @@ class IssuePaginatedViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -875,8 +888,11 @@ class IssuePaginatedViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -892,3 +908,173 @@ class IssuePaginatedViewSet(BaseViewSet): ) return Response(paginated_data, status=status.HTTP_200_OK) + + +class IssueDetailEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + issue = ( + Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + issue = issue.filter(**filters) + order_by_param = request.GET.get("order_by", "-created_at") + # Issue queryset + issue, order_by_param = order_issue_queryset( + issue_queryset=issue, order_by_param=order_by_param + ) + return self.paginate( + request=request, + order_by=order_by_param, + queryset=(issue), + on_results=lambda issue: IssueSerializer( + issue, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class IssueBulkUpdateDateEndpoint(BaseAPIView): + def validate_dates(self, current_start, current_target, new_start, new_target): + """ + Validate that start date is before target date. + """ + start = new_start or current_start + target = new_target or current_target + + if start and target and start > target: + return False + return True + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id): + updates = request.data.get("updates", []) + + issue_ids = [update["id"] for update in updates] + epoch = int(timezone.now().timestamp()) + + # Fetch all relevant issues in a single query + issues = list(Issue.objects.filter(id__in=issue_ids)) + issues_dict = {str(issue.id): issue for issue in issues} + issues_to_update = [] + + for update in updates: + issue_id = update["id"] + issue = issues_dict.get(issue_id) + + if not issue: + continue + + start_date = update.get("start_date") + target_date = update.get("target_date") + validate_dates = self.validate_dates( + issue.start_date, issue.target_date, start_date, target_date + ) + if not validate_dates: + return Response( + {"message": "Start date cannot exceed target date"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if start_date: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"start_date": update.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 = start_date + issues_to_update.append(issue) + + if target_date: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + {"target_date": update.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 = target_date + issues_to_update.append(issue) + + # Bulk update issues + Issue.objects.bulk_update(issues_to_update, ["start_date", "target_date"]) + + return Response( + {"message": "Issues updated successfully"}, status=status.HTTP_200_OK + ) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 12964dc3a..d072bb881 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -12,18 +12,9 @@ from rest_framework import status # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueCommentSerializer, - CommentReactionSerializer, -) +from plane.app.serializers import IssueCommentSerializer, CommentReactionSerializer from plane.app.permissions import allow_permission, ROLE -from plane.db.models import ( - IssueComment, - ProjectMember, - CommentReaction, - Project, - Issue, -) +from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue from plane.bgtasks.issue_activities_task import issue_activity @@ -32,10 +23,7 @@ class IssueCommentViewSet(BaseViewSet): model = IssueComment webhook_event = "issue_comment" - filterset_fields = [ - "issue__id", - "workspace__id", - ] + filterset_fields = ["issue__id", "workspace__id"] def get_queryset(self): return self.filter_queryset( @@ -65,13 +53,7 @@ class IssueCommentViewSet(BaseViewSet): .distinct() ) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def create(self, request, slug, project_id, issue_id): project = Project.objects.get(pk=project_id) issue = Issue.objects.get(pk=issue_id) @@ -93,15 +75,11 @@ class IssueCommentViewSet(BaseViewSet): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, - issue_id=issue_id, - actor=request.user, + project_id=project_id, issue_id=issue_id, actor=request.user ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps( - serializer.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -113,22 +91,14 @@ class IssueCommentViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN], - creator=True, - model=IssueComment, - ) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment) def partial_update(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) serializer = IssueCommentSerializer( issue_comment, data=request.data, partial=True @@ -149,19 +119,13 @@ class IssueCommentViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment - ) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment) def destroy(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder ) issue_comment.delete() issue_activity.delay( @@ -198,20 +162,12 @@ class CommentReactionViewSet(BaseViewSet): .distinct() ) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def create(self, request, slug, project_id, comment_id): serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, - actor_id=request.user.id, - comment_id=comment_id, + project_id=project_id, actor_id=request.user.id, comment_id=comment_id ) issue_activity.delay( type="comment_reaction.activity.created", @@ -227,13 +183,7 @@ class CommentReactionViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def destroy(self, request, slug, project_id, comment_id, reaction_code): comment_reaction = CommentReaction.objects.get( workspace__slug=slug, diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py deleted file mode 100644 index c5899d972..000000000 --- a/apiserver/plane/app/views/issue/draft.py +++ /dev/null @@ -1,410 +0,0 @@ -# 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 ( - Exists, - F, - Func, - OuterRef, - Prefetch, - Q, - UUIDField, - Value, -) -from django.db.models.functions import Coalesce -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page - -# Third Party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from plane.app.permissions import ProjectEntityPermission -from plane.app.serializers import ( - IssueCreateSerializer, - IssueDetailSerializer, - IssueFlatSerializer, - IssueSerializer, -) -from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Issue, - IssueAttachment, - IssueLink, - IssueReaction, - IssueSubscriber, - Project, - 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, -) -from .. import BaseViewSet - - -class IssueDraftViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .filter(deleted_at__isnull=True) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ).distinct() - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - - 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, - ) - - # 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 create(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save(is_draft=True) - - # Track the issue - issue_activity.delay( - type="issue_draft.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - - issue = ( - 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): - issue = self.get_queryset().filter(pk=pk).first() - - if not issue: - return Response( - {"error": "Issue does not exist"}, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = IssueCreateSerializer( - issue, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(issue).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - 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", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("created_by"), - ) - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=OuterRef("pk"), - subscriber=request.user, - ) - ) - ) - ).first() - - if not issue: - return Response( - {"error": "The required object does not exist."}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = IssueDetailSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - if issue.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the issue"}, - status=status.HTTP_403_FORBIDDEN, - ) - issue.delete() - issue_activity.delay( - type="issue_draft.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance={}, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py index 7a72932ff..b8a960161 100644 --- a/apiserver/plane/app/views/issue/label.py +++ b/apiserver/plane/app/views/issue/label.py @@ -12,19 +12,14 @@ from rest_framework import status from .. import BaseViewSet, BaseAPIView from plane.app.serializers import LabelSerializer from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE -from plane.db.models import ( - Project, - Label, -) +from plane.db.models import Project, Label from plane.utils.cache import invalidate_cache class LabelViewSet(BaseViewSet): serializer_class = LabelSerializer model = Label - permission_classes = [ - ProjectBasePermission, - ] + permission_classes = [ProjectBasePermission] def get_queryset(self): return self.filter_queryset( @@ -40,39 +35,27 @@ class LabelViewSet(BaseViewSet): .order_by("sort_order") ) - @invalidate_cache( - path="/api/workspaces/:slug/labels/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: return Response( - { - "error": "Label with the same name already exists in the project" - }, + {"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST, ) - @invalidate_cache( - path="/api/workspaces/:slug/labels/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) - @invalidate_cache( - path="/api/workspaces/:slug/labels/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py index 1a4110a2f..85faa8368 100644 --- a/apiserver/plane/app/views/issue/link.py +++ b/apiserver/plane/app/views/issue/link.py @@ -18,9 +18,7 @@ from plane.bgtasks.issue_activities_task import issue_activity class IssueLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] model = IssueLink serializer_class = IssueLinkSerializer @@ -44,15 +42,10 @@ class IssueLinkViewSet(BaseViewSet): def create(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - project_id=project_id, - issue_id=issue_id, - ) + serializer.save(project_id=project_id, issue_id=issue_id) issue_activity.delay( type="link.activity.created", - requested_data=json.dumps( - serializer.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -66,19 +59,13 @@ class IssueLinkViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, - ) - serializer = IssueLinkSerializer( - issue_link, data=request.data, partial=True + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -97,14 +84,10 @@ class IssueLinkViewSet(BaseViewSet): def destroy(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - pk=pk, + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, - cls=DjangoJSONEncoder, + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) issue_activity.delay( type="link.activity.deleted", diff --git a/apiserver/plane/app/views/issue/reaction.py b/apiserver/plane/app/views/issue/reaction.py index a43f7bda6..7fe53b456 100644 --- a/apiserver/plane/app/views/issue/reaction.py +++ b/apiserver/plane/app/views/issue/reaction.py @@ -42,9 +42,7 @@ class IssueReactionViewSet(BaseViewSet): serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - issue_id=issue_id, - project_id=project_id, - actor=request.user, + issue_id=issue_id, project_id=project_id, actor=request.user ) issue_activity.delay( type="issue_reaction.activity.created", @@ -76,10 +74,7 @@ class IssueReactionViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(issue_reaction.id), - } + {"reaction": str(reaction_code), "identifier": str(issue_reaction.id)} ), epoch=int(timezone.now().timestamp()), notification=True, diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index e69614747..35d88a54b 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -3,7 +3,7 @@ import json # Django imports from django.utils import timezone -from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField +from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField, Subquery from django.core.serializers.json import DjangoJSONEncoder from django.db.models.functions import Coalesce from django.contrib.postgres.aggregates import ArrayAgg @@ -15,27 +15,24 @@ from rest_framework import status # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueRelationSerializer, - RelatedIssueSerializer, -) +from plane.app.serializers import IssueRelationSerializer, RelatedIssueSerializer from plane.app.permissions import ProjectEntityPermission from plane.db.models import ( Project, IssueRelation, Issue, - IssueAttachment, + FileAsset, IssueLink, + CycleIssue, ) from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.issue_relation_mapper import get_actual_relation class IssueRelationViewSet(BaseViewSet): serializer_class = IssueRelationSerializer model = IssueRelation - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def list(self, request, slug, project_id, issue_id): issue_relations = ( @@ -79,11 +76,37 @@ class IssueRelationViewSet(BaseViewSet): related_issue_id=issue_id, relation_type="relates_to" ).values_list("issue_id", flat=True) + # get all start after issues + start_after_issues = issue_relations.filter( + relation_type="start_before", related_issue_id=issue_id + ).values_list("issue_id", flat=True) + + # get all start_before issues + start_before_issues = issue_relations.filter( + relation_type="start_before", issue_id=issue_id + ).values_list("related_issue_id", flat=True) + + # get all finish after issues + finish_after_issues = issue_relations.filter( + relation_type="finish_before", related_issue_id=issue_id + ).values_list("issue_id", flat=True) + + # get all finish before issues + finish_before_issues = issue_relations.filter( + relation_type="finish_before", issue_id=issue_id + ).values_list("related_issue_id", flat=True) + queryset = ( Issue.issue_objects.filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -91,17 +114,16 @@ class IssueRelationViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -111,7 +133,10 @@ class IssueRelationViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & (Q(label_issue__deleted_at__isnull=True)) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -119,8 +144,11 @@ class IssueRelationViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -147,46 +175,34 @@ class IssueRelationViewSet(BaseViewSet): response_data = { "blocking": queryset.filter(pk__in=blocking_issues) - .annotate( - relation_type=Value("blocking", output_field=CharField()) - ) + .annotate(relation_type=Value("blocking", output_field=CharField())) .values(*fields), "blocked_by": queryset.filter(pk__in=blocked_by_issues) - .annotate( - relation_type=Value("blocked_by", output_field=CharField()) - ) + .annotate(relation_type=Value("blocked_by", output_field=CharField())) .values(*fields), "duplicate": queryset.filter(pk__in=duplicate_issues) - .annotate( - relation_type=Value( - "duplicate", - output_field=CharField(), - ) - ) + .annotate(relation_type=Value("duplicate", output_field=CharField())) .values(*fields) | queryset.filter(pk__in=duplicate_issues_related) - .annotate( - relation_type=Value( - "duplicate", - output_field=CharField(), - ) - ) + .annotate(relation_type=Value("duplicate", output_field=CharField())) .values(*fields), "relates_to": queryset.filter(pk__in=relates_to_issues) - .annotate( - relation_type=Value( - "relates_to", - output_field=CharField(), - ) - ) + .annotate(relation_type=Value("relates_to", output_field=CharField())) .values(*fields) | queryset.filter(pk__in=relates_to_issues_related) - .annotate( - relation_type=Value( - "relates_to", - output_field=CharField(), - ) - ) + .annotate(relation_type=Value("relates_to", output_field=CharField())) + .values(*fields), + "start_after": queryset.filter(pk__in=start_after_issues) + .annotate(relation_type=Value("start_after", output_field=CharField())) + .values(*fields), + "start_before": queryset.filter(pk__in=start_before_issues) + .annotate(relation_type=Value("start_before", output_field=CharField())) + .values(*fields), + "finish_after": queryset.filter(pk__in=finish_after_issues) + .annotate(relation_type=Value("finish_after", output_field=CharField())) + .values(*fields), + "finish_before": queryset.filter(pk__in=finish_before_issues) + .annotate(relation_type=Value("finish_before", output_field=CharField())) .values(*fields), } @@ -194,6 +210,12 @@ class IssueRelationViewSet(BaseViewSet): def create(self, request, slug, project_id, issue_id): relation_type = request.data.get("relation_type", None) + if relation_type is None: + return Response( + {"message": "Issue relation type is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + issues = request.data.get("issues", []) project = Project.objects.get(pk=project_id) @@ -201,16 +223,16 @@ class IssueRelationViewSet(BaseViewSet): [ IssueRelation( issue_id=( - issue if relation_type == "blocking" else issue_id + issue + if relation_type in ["blocking", "start_after", "finish_after"] + else issue_id ), related_issue_id=( - issue_id if relation_type == "blocking" else issue - ), - relation_type=( - "blocked_by" - if relation_type == "blocking" - else relation_type + issue_id + if relation_type in ["blocking", "start_after", "finish_after"] + else issue ), + relation_type=(get_actual_relation(relation_type)), project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, @@ -234,7 +256,7 @@ class IssueRelationViewSet(BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) - if relation_type == "blocking": + if relation_type in ["blocking", "start_after", "finish_after"]: return Response( RelatedIssueSerializer(issue_relation, many=True).data, status=status.HTTP_201_CREATED, @@ -249,7 +271,7 @@ class IssueRelationViewSet(BaseViewSet): relation_type = request.data.get("relation_type", None) related_issue = request.data.get("related_issue", None) - if relation_type == "blocking": + if relation_type in ["blocking", "start_after", "finish_after"]: issue_relation = IssueRelation.objects.get( workspace__slug=slug, project_id=project_id, @@ -264,10 +286,9 @@ class IssueRelationViewSet(BaseViewSet): related_issue_id=related_issue, ) current_instance = json.dumps( - IssueRelationSerializer(issue_relation).data, - cls=DjangoJSONEncoder, + IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder ) - issue_relation.delete(soft=False) + issue_relation.delete() issue_activity.delay( type="issue_relation.activity.deleted", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 9496f1751..e461917fb 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -3,14 +3,7 @@ import json # Django imports from django.utils import timezone -from django.db.models import ( - OuterRef, - Func, - F, - Q, - Value, - UUIDField, -) +from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg @@ -25,30 +18,28 @@ from rest_framework import status from .. import BaseAPIView from plane.app.serializers import IssueSerializer from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - Issue, - IssueLink, - IssueAttachment, -) +from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.user_timezone_converter import user_timezone_converter from collections import defaultdict class SubIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): sub_issues = ( - Issue.issue_objects.filter( - parent_id=issue_id, workspace__slug=slug - ) + Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -56,17 +47,16 @@ class SubIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -76,7 +66,10 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -84,8 +77,11 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -93,7 +89,11 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -139,10 +139,7 @@ class SubIssuesEndpoint(BaseAPIView): sub_issues, datetime_fields, request.user.user_timezone ) return Response( - { - "sub_issues": sub_issues, - "state_distribution": result, - }, + {"sub_issues": sub_issues, "state_distribution": result}, status=status.HTTP_200_OK, ) @@ -164,9 +161,9 @@ class SubIssuesEndpoint(BaseAPIView): _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - updated_sub_issues = Issue.issue_objects.filter( - id__in=sub_issue_ids - ).annotate(state_group=F("state__group")) + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate( + state_group=F("state__group") + ) # Track the issue _ = [ @@ -189,14 +186,8 @@ class SubIssuesEndpoint(BaseAPIView): for sub_issue in updated_sub_issues: result[sub_issue.state_group].append(str(sub_issue.id)) - serializer = IssueSerializer( - updated_sub_issues, - many=True, - ) + serializer = IssueSerializer(updated_sub_issues, many=True) return Response( - { - "sub_issues": serializer.data, - "state_distribution": result, - }, + {"sub_issues": serializer.data, "state_distribution": result}, status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/issue/subscriber.py b/apiserver/plane/app/views/issue/subscriber.py index dc727de28..58f3ba4c7 100644 --- a/apiserver/plane/app/views/issue/subscriber.py +++ b/apiserver/plane/app/views/issue/subscriber.py @@ -4,37 +4,22 @@ from rest_framework import status # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueSubscriberSerializer, - ProjectMemberLiteSerializer, -) -from plane.app.permissions import ( - ProjectEntityPermission, - ProjectLitePermission, -) -from plane.db.models import ( - IssueSubscriber, - ProjectMember, -) +from plane.app.serializers import IssueSubscriberSerializer, ProjectMemberLiteSerializer +from plane.app.permissions import ProjectEntityPermission, ProjectLitePermission +from plane.db.models import IssueSubscriber, ProjectMember class IssueSubscriberViewSet(BaseViewSet): serializer_class = IssueSubscriberSerializer model = IssueSubscriber - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_permissions(self): if self.action in ["subscribe", "unsubscribe", "subscription_status"]: - self.permission_classes = [ - ProjectLitePermission, - ] + self.permission_classes = [ProjectLitePermission] else: - self.permission_classes = [ - ProjectEntityPermission, - ] + self.permission_classes = [ProjectEntityPermission] return super(IssueSubscriberViewSet, self).get_permissions() @@ -62,9 +47,7 @@ class IssueSubscriberViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): members = ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - is_active=True, + workspace__slug=slug, project_id=project_id, is_active=True ).select_related("member") serializer = ProjectMemberLiteSerializer(members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -77,9 +60,7 @@ class IssueSubscriberViewSet(BaseViewSet): issue=issue_id, ) issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) + return Response(status=status.HTTP_204_NO_CONTENT) def subscribe(self, request, slug, project_id, issue_id): if IssueSubscriber.objects.filter( @@ -94,9 +75,7 @@ class IssueSubscriberViewSet(BaseViewSet): ) subscriber = IssueSubscriber.objects.create( - issue_id=issue_id, - subscriber_id=request.user.id, - project_id=project_id, + issue_id=issue_id, subscriber_id=request.user.id, project_id=project_id ) serializer = IssueSubscriberSerializer(subscriber) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -109,9 +88,7 @@ class IssueSubscriberViewSet(BaseViewSet): issue=issue_id, ) issue_subscriber.delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) + return Response(status=status.HTTP_204_NO_CONTENT) def subscription_status(self, request, slug, project_id, issue_id): issue_subscriber = IssueSubscriber.objects.filter( @@ -120,6 +97,4 @@ class IssueSubscriberViewSet(BaseViewSet): workspace__slug=slug, project=project_id, ).exists() - return Response( - {"subscribed": issue_subscriber}, status=status.HTTP_200_OK - ) + return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index b38d83487..82c1d47eb 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -14,19 +14,18 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone +from django.db import models # 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 ( - ModuleDetailSerializer, -) +from plane.app.permissions import ProjectEntityPermission +from plane.app.serializers import ModuleDetailSerializer 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 @@ -37,10 +36,7 @@ from .. import BaseAPIView class ModuleArchiveUnarchiveEndpoint(BaseAPIView): - - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] def get_queryset(self): favorite_subquery = UserFavorite.objects.filter( @@ -54,6 +50,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( state__group="cancelled", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -63,6 +60,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( state__group="completed", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -72,6 +70,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( state__group="started", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -81,6 +80,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( state__group="unstarted", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -90,6 +90,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( state__group="backlog", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -98,6 +99,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): total_issues = ( Issue.issue_objects.filter( issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -108,6 +110,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="completed", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -122,12 +125,11 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Issue.issue_objects.filter( estimate_point__estimate__type="points", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - total_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) ) .values("total_estimate_points")[:1] ) @@ -136,12 +138,11 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="backlog", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - backlog_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("backlog_estimate_point")[:1] ) @@ -150,6 +151,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="unstarted", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -164,12 +166,11 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="started", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - started_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("started_estimate_point")[:1] ) @@ -178,6 +179,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): estimate_point__estimate__type="points", state__group="cancelled", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -197,9 +199,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) .annotate( @@ -216,8 +216,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) .annotate( started_issues=Coalesce( - Subquery(started_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(started_issues[:1]), Value(0, output_field=IntegerField()) ) ) .annotate( @@ -228,51 +227,48 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) .annotate( backlog_issues=Coalesce( - Subquery(backlog_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()) ) ) .annotate( total_issues=Coalesce( - Subquery(total_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(total_issues[:1]), 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()), - ), + Subquery(total_estimate_point), Value(0, output_field=FloatField()) + ) ) .annotate( member_ids=Coalesce( @@ -334,6 +330,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): project_id=self.kwargs.get("project_id"), parent__isnull=False, issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -357,6 +354,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): assignee_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -364,18 +362,35 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ), + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -411,9 +426,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ), + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -441,19 +454,18 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): 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, - ) + data["estimate_distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, ) assignee_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -461,22 +473,37 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -504,6 +531,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): label_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -513,12 +541,8 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -557,10 +581,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): module_id=pk, ) - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, module_id): module = Module.objects.get( @@ -568,9 +589,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) if module.status not in ["completed", "cancelled"]: return Response( - { - "error": "Only completed or cancelled modules can be archived" - }, + {"error": "Only completed or cancelled modules can be archived"}, status=status.HTTP_400_BAD_REQUEST, ) module.archived_at = timezone.now() @@ -582,8 +601,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): workspace__slug=slug, ).delete() return Response( - {"archived_at": str(module.archived_at)}, - status=status.HTTP_200_OK, + {"archived_at": str(module.archived_at)}, status=status.HTTP_200_OK ) def delete(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index d09848fd9..8f9839b71 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -18,8 +18,11 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone @@ -30,6 +33,7 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import ( ProjectEntityPermission, + ProjectLitePermission, allow_permission, ROLE, ) @@ -81,6 +85,7 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( state__group="cancelled", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -90,6 +95,7 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( state__group="completed", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -99,6 +105,7 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( state__group="started", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -108,6 +115,7 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( state__group="unstarted", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -117,6 +125,7 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( state__group="backlog", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -125,6 +134,7 @@ class ModuleViewSet(BaseViewSet): total_issues = ( Issue.issue_objects.filter( issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate(cnt=Count("pk")) @@ -135,6 +145,7 @@ class ModuleViewSet(BaseViewSet): estimate_point__estimate__type="points", state__group="completed", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -149,12 +160,11 @@ class ModuleViewSet(BaseViewSet): Issue.issue_objects.filter( estimate_point__estimate__type="points", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - total_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) + total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) ) .values("total_estimate_points")[:1] ) @@ -163,12 +173,11 @@ class ModuleViewSet(BaseViewSet): estimate_point__estimate__type="points", state__group="backlog", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - backlog_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("backlog_estimate_point")[:1] ) @@ -177,6 +186,7 @@ class ModuleViewSet(BaseViewSet): estimate_point__estimate__type="points", state__group="unstarted", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -191,12 +201,11 @@ class ModuleViewSet(BaseViewSet): estimate_point__estimate__type="points", state__group="started", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( - started_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) + started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) ) .values("started_estimate_point")[:1] ) @@ -205,6 +214,7 @@ class ModuleViewSet(BaseViewSet): estimate_point__estimate__type="points", state__group="cancelled", issue_module__module_id=OuterRef("pk"), + issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") .annotate( @@ -227,9 +237,7 @@ class ModuleViewSet(BaseViewSet): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) .annotate( @@ -246,8 +254,7 @@ class ModuleViewSet(BaseViewSet): ) .annotate( started_issues=Coalesce( - Subquery(started_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(started_issues[:1]), Value(0, output_field=IntegerField()) ) ) .annotate( @@ -258,51 +265,48 @@ class ModuleViewSet(BaseViewSet): ) .annotate( backlog_issues=Coalesce( - Subquery(backlog_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()) ) ) .annotate( total_issues=Coalesce( - Subquery(total_issues[:1]), - Value(0, output_field=IntegerField()), + Subquery(total_issues[:1]), 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()), - ), + Subquery(total_estimate_point), Value(0, output_field=FloatField()) + ) ) .annotate( member_ids=Coalesce( @@ -317,13 +321,7 @@ class ModuleViewSet(BaseViewSet): .order_by("-is_favorite", "-created_at") ) - allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) serializer = ModuleWriteSerializer( @@ -386,16 +384,11 @@ class ModuleViewSet(BaseViewSet): return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) if self.fields: - modules = ModuleSerializer( - queryset, - many=True, - fields=self.fields, - ).data + modules = ModuleSerializer(queryset, many=True, fields=self.fields).data else: modules = queryset.values( # Required fields "id", @@ -435,13 +428,7 @@ class ModuleViewSet(BaseViewSet): ) return Response(modules, status=status.HTTP_200_OK) - allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def retrieve(self, request, slug, project_id, pk): queryset = ( self.get_queryset() @@ -452,6 +439,7 @@ class ModuleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), parent__isnull=False, issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -461,8 +449,7 @@ class ModuleViewSet(BaseViewSet): if not queryset.exists(): return Response( - {"error": "Module not found"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Module not found"}, status=status.HTTP_404_NOT_FOUND ) estimate_type = Project.objects.filter( @@ -481,6 +468,7 @@ class ModuleViewSet(BaseViewSet): assignee_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -488,18 +476,35 @@ class ModuleViewSet(BaseViewSet): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ), + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -527,6 +532,7 @@ class ModuleViewSet(BaseViewSet): label_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -535,9 +541,7 @@ class ModuleViewSet(BaseViewSet): .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") .annotate( - total_estimates=Sum( - Cast("estimate_point__value", FloatField()) - ), + total_estimates=Sum(Cast("estimate_point__value", FloatField())) ) .annotate( completed_estimates=Sum( @@ -565,19 +569,18 @@ class ModuleViewSet(BaseViewSet): 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, - ) + data["estimate_distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, ) assignee_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -585,22 +588,32 @@ class ModuleViewSet(BaseViewSet): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( - "first_name", - "last_name", - "assignee_id", - "avatar", - "display_name", + "first_name", "last_name", "assignee_id", "avatar_url", "display_name" ) .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -628,6 +641,7 @@ class ModuleViewSet(BaseViewSet): label_distribution = ( Issue.issue_objects.filter( issue_module__module_id=pk, + issue_module__deleted_at__isnull=True, workspace__slug=slug, project_id=project_id, ) @@ -637,12 +651,8 @@ class ModuleViewSet(BaseViewSet): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "id", - filter=Q( - archived_at__isnull=True, - is_draft=False, - ), - ), + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) ) .annotate( completed_issues=Count( @@ -672,7 +682,13 @@ class ModuleViewSet(BaseViewSet): "labels": label_distribution, "completion_chart": {}, } - if modules and modules.start_date and modules.target_date: + + if ( + modules + and modules.start_date + and modules.target_date + and modules.total_issues > 0 + ): data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, @@ -689,10 +705,7 @@ class ModuleViewSet(BaseViewSet): project_id=project_id, ) - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): @@ -766,14 +779,10 @@ class ModuleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN], creator=True, model=Module) def destroy(self, request, slug, project_id, pk): - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list( - "issue", flat=True - ) + ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) ) _ = [ issue_activity.delay( @@ -791,10 +800,7 @@ class ModuleViewSet(BaseViewSet): ] module.delete() # Delete the module issues - ModuleIssue.objects.filter( - module=pk, - project_id=project_id, - ).delete() + ModuleIssue.objects.filter(module=pk, project_id=project_id).delete() # Delete the user favorite module UserFavorite.objects.filter( user=request.user, @@ -806,9 +812,7 @@ class ModuleViewSet(BaseViewSet): class ModuleLinkViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] + permission_classes = [ProjectEntityPermission] model = ModuleLink serializer_class = ModuleLinkSerializer @@ -838,6 +842,7 @@ class ModuleLinkViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet): model = UserFavorite + permission_classes = [ProjectLitePermission] def get_queryset(self): return self.filter_queryset( @@ -870,7 +875,6 @@ class ModuleFavoriteViewSet(BaseViewSet): class ModuleUserPropertiesEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id, module_id): module_properties = ModuleUserProperties.objects.get( diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index eb63890d2..06b0a2fb1 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -1,12 +1,7 @@ # Python imports import json -from django.db.models import ( - F, - Func, - OuterRef, - Q, -) +from django.db.models import F, Func, OuterRef, Q, Subquery # Django Imports from django.utils import timezone @@ -18,16 +13,15 @@ from rest_framework import status from rest_framework.response import Response from plane.app.permissions import allow_permission, ROLE -from plane.app.serializers import ( - ModuleIssueSerializer, -) +from plane.app.serializers import ModuleIssueSerializer from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, ModuleIssue, Project, + CycleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -36,10 +30,7 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator # Module imports from .. import BaseViewSet @@ -51,10 +42,7 @@ class ModuleIssueViewSet(BaseViewSet): webhook_event = "module_issue" bulk = True - filterset_fields = [ - "issue__labels__id", - "issue__assignees__id", - ] + filterset_fields = ["issue__labels__id", "issue__assignees__id"] def get_queryset(self): return ( @@ -62,10 +50,17 @@ class ModuleIssueViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), issue_module__module_id=self.kwargs.get("module_id"), + issue_module__deleted_at__isnull=True, ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -73,17 +68,16 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -91,12 +85,7 @@ class ModuleIssueViewSet(BaseViewSet): ).distinct() @method_decorator(gzip_page) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id, module_id): filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) @@ -104,8 +93,7 @@ class ModuleIssueViewSet(BaseViewSet): # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -114,9 +102,7 @@ class ModuleIssueViewSet(BaseViewSet): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -136,9 +122,7 @@ class ModuleIssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -156,10 +140,10 @@ class ModuleIssueViewSet(BaseViewSet): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -172,9 +156,7 @@ class ModuleIssueViewSet(BaseViewSet): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -185,10 +167,10 @@ class ModuleIssueViewSet(BaseViewSet): ), 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -210,8 +192,7 @@ class ModuleIssueViewSet(BaseViewSet): issues = request.data.get("issues", []) if not issues: return Response( - {"error": "Issues are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST ) project = Project.objects.get(pk=project_id) _ = ModuleIssue.objects.bulk_create( diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index b8e34339c..d2aa1a02d 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -41,8 +41,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): ) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): # Get query parameters @@ -53,9 +52,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator): mentioned = request.GET.get("mentioned", False) q_filters = Q() - inbox_issue = Issue.objects.filter( + intake_issue = Issue.objects.filter( pk=OuterRef("entity_identifier"), - issue_inbox__status__in=[0, 2, -2], + issue_intake__status__in=[0, 2, -2], workspace__slug=self.kwargs.get("slug"), ) @@ -64,7 +63,8 @@ class NotificationViewSet(BaseViewSet, BasePaginator): workspace__slug=slug, receiver_id=request.user.id ) .filter(entity_name="issue") - .annotate(is_inbox_issue=Exists(inbox_issue)) + .annotate(is_inbox_issue=Exists(intake_issue)) + .annotate(is_intake_issue=Exists(intake_issue)) .annotate( is_mentioned_notification=Case( When(sender__icontains="mentioned", then=True), @@ -78,10 +78,8 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Filters based on query parameters snoozed_filters = { - "true": Q(snoozed_till__lt=timezone.now()) - | Q(snoozed_till__isnull=False), - "false": Q(snoozed_till__gte=timezone.now()) - | Q(snoozed_till__isnull=True), + "true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False), + "false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), } notifications = notifications.filter(snoozed_filters[snoozed]) @@ -102,9 +100,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): if mentioned: notifications = notifications.filter(sender__icontains="mentioned") else: - notifications = notifications.exclude( - sender__icontains="mentioned" - ) + notifications = notifications.exclude(sender__icontains="mentioned") type = type.split(",") # Subscribed issues @@ -142,10 +138,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Created issues if "created" in type: if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role__lt=15, - is_active=True, + workspace__slug=slug, member=request.user, role__lt=15, is_active=True ).exists(): notifications = notifications.none() else: @@ -158,9 +151,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): notifications = notifications.filter(q_filters) # Pagination - if request.GET.get("per_page", False) and request.GET.get( - "cursor", False - ): + 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, @@ -174,17 +165,14 @@ class NotificationViewSet(BaseViewSet, BasePaginator): return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def partial_update(self, request, slug, pk): notification = Notification.objects.get( workspace__slug=slug, pk=pk, receiver=request.user ) # Only read_at and snoozed_till can be updated - notification_data = { - "snoozed_till": request.data.get("snoozed_till", None), - } + notification_data = {"snoozed_till": request.data.get("snoozed_till", None)} serializer = NotificationSerializer( notification, data=notification_data, partial=True ) @@ -245,8 +233,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): class UnreadNotificationEndpoint(BaseAPIView): @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def get(self, request, slug): # Watching Issues Count @@ -273,12 +260,8 @@ class UnreadNotificationEndpoint(BaseAPIView): return Response( { - "total_unread_notifications_count": int( - unread_notifications_count - ), - "mention_unread_notifications_count": int( - mention_notifications_count - ), + "total_unread_notifications_count": int(unread_notifications_count), + "mention_unread_notifications_count": int(mention_notifications_count), }, status=status.HTTP_200_OK, ) @@ -295,9 +278,7 @@ class MarkAllReadNotificationViewSet(BaseViewSet): notifications = ( Notification.objects.filter( - workspace__slug=slug, - receiver_id=request.user.id, - read_at__isnull=True, + workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True ) .select_related("workspace", "project", "triggered_by", "receiver") .order_by("snoozed_till", "-created_at") @@ -306,13 +287,11 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # Filter for snoozed notifications if snoozed: notifications = notifications.filter( - Q(snoozed_till__lt=timezone.now()) - | Q(snoozed_till__isnull=False) + Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) ) else: notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) - | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True) ) # Filter for archived or unarchive @@ -326,35 +305,26 @@ class MarkAllReadNotificationViewSet(BaseViewSet): issue_ids = IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter( - entity_identifier__in=issue_ids - ) + notifications = notifications.filter(entity_identifier__in=issue_ids) # Assigned Issues if type == "assigned": 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 - ) + notifications = notifications.filter(entity_identifier__in=issue_ids) # Created issues if type == "created": if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role__lt=15, - is_active=True, + workspace__slug=slug, member=request.user, role__lt=15, is_active=True ).exists(): notifications = Notification.objects.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 - ) + notifications = notifications.filter(entity_identifier__in=issue_ids) updated_notifications = [] for notification in notifications: @@ -375,9 +345,7 @@ class UserNotificationPreferenceEndpoint(BaseAPIView): user_notification_preference = UserNotificationPreference.objects.get( user=request.user ) - serializer = UserNotificationPreferenceSerializer( - user_notification_preference - ) + serializer = UserNotificationPreferenceSerializer(user_notification_preference) return Response(serializer.data, status=status.HTTP_200_OK) # update the object diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index bb4814e47..24ceb2d3f 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -18,7 +18,7 @@ from django.db.models.functions import Coalesce from rest_framework import status from rest_framework.response import Response - +# Module imports from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( PageLogSerializer, @@ -35,10 +35,7 @@ from plane.db.models import ( Project, ) from plane.utils.error_codes import ERROR_CODES - -# Module imports from ..base import BaseAPIView, BaseViewSet - from plane.bgtasks.page_transaction_task import page_transaction from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task @@ -63,9 +60,7 @@ def unarchive_archive_page_and_descendants(page_id, archived_at): class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page - search_fields = [ - "name", - ] + search_fields = ["name"] def get_queryset(self): subquery = UserFavorite.objects.filter( @@ -95,8 +90,7 @@ class PageViewSet(BaseViewSet): .annotate( project=Exists( ProjectPage.objects.filter( - page_id=OuterRef("id"), - project_id=self.kwargs.get("project_id"), + page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") ) ) ) @@ -111,9 +105,7 @@ class PageViewSet(BaseViewSet): ), project_ids=Coalesce( ArrayAgg( - "projects__id", - distinct=True, - filter=~Q(projects__id=True), + "projects__id", distinct=True, filter=~Q(projects__id=True) ), Value([], output_field=ArrayField(UUIDField())), ), @@ -129,9 +121,7 @@ class PageViewSet(BaseViewSet): context={ "project_id": project_id, "owned_by_id": request.user.id, - "description_html": request.data.get( - "description_html", "

" - ), + "description_html": request.data.get("description_html", "

"), }, ) @@ -148,23 +138,18 @@ class PageViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( - pk=pk, - workspace__slug=slug, - projects__id=project_id, + pk=pk, workspace__slug=slug, projects__id=project_id ) if page.is_locked: return Response( - {"error": "Page is locked"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST ) parent = request.data.get("parent", None) if parent: _ = Page.objects.get( - pk=parent, - workspace__slug=slug, - projects__id=project_id, + pk=parent, workspace__slug=slug, projects__id=project_id ) # Only update access if the page owner is the requesting user @@ -179,9 +164,7 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = PageDetailSerializer( - page, data=request.data, partial=True - ) + serializer = PageDetailSerializer(page, data=request.data, partial=True) page_description = page.description_html if serializer.is_valid(): serializer.save() @@ -190,18 +173,14 @@ class PageViewSet(BaseViewSet): page_transaction.delay( new_value=request.data, old_value=json.dumps( - { - "description_html": page_description, - }, + {"description_html": page_description}, cls=DjangoJSONEncoder, ), page_id=pk, ) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Page.DoesNotExist: return Response( { @@ -210,13 +189,7 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def retrieve(self, request, slug, project_id, pk=None): page = self.get_queryset().filter(pk=pk).first() project = Project.objects.get(pk=project_id) @@ -244,8 +217,7 @@ class PageViewSet(BaseViewSet): if page is None: return Response( - {"error": "Page not found"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND ) else: issue_ids = PageLog.objects.filter( @@ -260,10 +232,7 @@ class PageViewSet(BaseViewSet): user_id=request.user.id, project_id=project_id, ) - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def lock(self, request, slug, project_id, pk): @@ -309,13 +278,7 @@ class PageViewSet(BaseViewSet): page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(pk=project_id) @@ -335,17 +298,12 @@ class PageViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def archive(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) # only the owner or admin can archive the page if ( ProjectMember.objects.filter( - project_id=project_id, - member=request.user, - is_active=True, - role__lte=15, + project_id=project_id, member=request.user, is_active=True, role__lte=15 ).exists() and request.user.id != page.owned_by_id ): @@ -363,24 +321,16 @@ class PageViewSet(BaseViewSet): unarchive_archive_page_and_descendants(pk, datetime.now()) - return Response( - {"archived_at": str(datetime.now())}, - status=status.HTTP_200_OK, - ) + return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def unarchive(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) # only the owner or admin can un archive the page if ( ProjectMember.objects.filter( - project_id=project_id, - member=request.user, - is_active=True, - role__lte=15, + project_id=project_id, member=request.user, is_active=True, role__lte=15 ).exists() and request.user.id != page.owned_by_id ): @@ -400,9 +350,7 @@ class PageViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN], creator=True, model=Page) def destroy(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) if page.archived_at is None: return Response( @@ -441,7 +389,6 @@ class PageViewSet(BaseViewSet): class PageFavoriteViewSet(BaseViewSet): - model = UserFavorite @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -468,7 +415,6 @@ class PageFavoriteViewSet(BaseViewSet): class PageLogEndpoint(BaseAPIView): - serializer_class = PageLogSerializer model = PageLog @@ -507,7 +453,6 @@ class PageLogEndpoint(BaseAPIView): class SubPagesEndpoint(BaseAPIView): - @method_decorator(gzip_page) def get(self, request, slug, project_id, page_id): pages = ( @@ -525,27 +470,15 @@ class SubPagesEndpoint(BaseAPIView): class PagesDescriptionViewSet(BaseViewSet): - - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ] - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def retrieve(self, request, slug, project_id, pk): page = ( - Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + 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 None: - return Response( - {"error": "Page not found"}, - status=404, - ) + return Response({"error": "Page not found"}, status=404) binary_data = page.description_binary def stream_data(): @@ -557,26 +490,19 @@ class PagesDescriptionViewSet(BaseViewSet): response = StreamingHttpResponse( stream_data(), content_type="application/octet-stream" ) - response["Content-Disposition"] = ( - 'attachment; filename="page_description.bin"' - ) + response["Content-Disposition"] = 'attachment; filename="page_description.bin"' return response @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): page = ( - Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + 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 None: - return Response( - {"error": "Page not found"}, - status=404, - ) + return Response({"error": "Page not found"}, status=404) if page.is_locked: return Response( @@ -598,10 +524,7 @@ class PagesDescriptionViewSet(BaseViewSet): # Serialize the existing instance existing_instance = json.dumps( - { - "description_html": page.description_html, - }, - cls=DjangoJSONEncoder, + {"description_html": page.description_html}, cls=DjangoJSONEncoder ) # Get the base64 data from the request @@ -614,9 +537,7 @@ class PagesDescriptionViewSet(BaseViewSet): # capture the page transaction if request.data.get("description_html"): page_transaction.delay( - new_value=request.data, - old_value=existing_instance, - page_id=pk, + new_value=request.data, old_value=existing_instance, page_id=pk ) # Store the updated binary data page.description_binary = new_binary_data diff --git a/apiserver/plane/app/views/page/version.py b/apiserver/plane/app/views/page/version.py index 1152be4f7..bcf2f4f5b 100644 --- a/apiserver/plane/app/views/page/version.py +++ b/apiserver/plane/app/views/page/version.py @@ -5,34 +5,25 @@ from rest_framework.response import Response # Module imports from plane.db.models import PageVersion from ..base import BaseAPIView -from plane.app.serializers import ( - PageVersionSerializer, - PageVersionDetailSerializer, -) +from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer from plane.app.permissions import allow_permission, ROLE class PageVersionEndpoint(BaseAPIView): - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST] - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, page_id, pk=None): # Check if pk is provided if pk: # Return a single page version page_version = PageVersion.objects.get( - workspace__slug=slug, - page_id=page_id, - pk=pk, + workspace__slug=slug, page_id=page_id, pk=pk ) # Serialize the page version serializer = PageVersionDetailSerializer(page_version) return Response(serializer.data, status=status.HTTP_200_OK) # Return all page versions page_versions = PageVersion.objects.filter( - workspace__slug=slug, - page_id=page_id, + workspace__slug=slug, page_id=page_id ) # Serialize the page versions serializer = PageVersionSerializer(page_versions, many=True) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 3ca034467..16b2a6e77 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -6,15 +6,7 @@ import json # Django imports from django.db import IntegrityError -from django.db.models import ( - Exists, - F, - Func, - OuterRef, - Prefetch, - Q, - Subquery, -) +from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -30,15 +22,11 @@ from plane.app.serializers import ( DeployBoardSerializer, ) -from plane.app.permissions import ( - ProjectMemberPermission, - allow_permission, - ROLE, -) +from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE from plane.db.models import ( UserFavorite, Cycle, - Inbox, + Intake, DeployBoard, IssueUserProperty, Issue, @@ -53,6 +41,7 @@ from plane.db.models import ( from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.exception_logger import log_exception class ProjectViewSet(BaseViewSet): @@ -72,10 +61,7 @@ class ProjectViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .select_related( - "workspace", - "workspace__owner", - "default_assignee", - "project_lead", + "workspace", "workspace__owner", "default_assignee", "project_lead" ) .annotate( is_favorite=Exists( @@ -99,9 +85,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), - member__is_bot=False, - is_active=True, + project_id=OuterRef("id"), member__is_bot=False, is_active=True ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -138,8 +122,7 @@ class ProjectViewSet(BaseViewSet): Prefetch( "project_projectmember", queryset=ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - is_active=True, + workspace__slug=self.kwargs.get("slug"), is_active=True ).select_related("member"), to_attr="members_list", ) @@ -148,21 +131,13 @@ class ProjectViewSet(BaseViewSet): ) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( - member=request.user, - workspace__slug=slug, - is_active=True, - role=5, + member=request.user, workspace__slug=slug, is_active=True, role=5 ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -170,10 +145,7 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, - workspace__slug=slug, - is_active=True, - role=15, + member=request.user, workspace__slug=slug, is_active=True, role=15 ).exists(): projects = projects.filter( Q( @@ -183,9 +155,7 @@ class ProjectViewSet(BaseViewSet): | Q(network=2) ) - if request.GET.get("per_page", False) and request.GET.get( - "cursor", False - ): + 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, @@ -201,17 +171,20 @@ class ProjectViewSet(BaseViewSet): return Response(projects, status=status.HTTP_200_OK) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def retrieve(self, request, slug, pk): project = ( self.get_queryset() + .filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) .filter(archived_at__isnull=True) .filter(pk=pk) .annotate( total_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk"), + project_id=self.kwargs.get("pk") ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -219,8 +192,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( sub_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk"), - parent__isnull=False, + project_id=self.kwargs.get("pk"), parent__isnull=False ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -228,8 +200,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( archived_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - archived_at__isnull=False, + project_id=self.kwargs.get("pk"), archived_at__isnull=False ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -247,8 +218,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( draft_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - is_draft=True, + project_id=self.kwargs.get("pk"), is_draft=True ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -268,8 +238,7 @@ class ProjectViewSet(BaseViewSet): if project is None: return Response( - {"error": "Project does not exist"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) recent_visited_task.delay( @@ -296,14 +265,11 @@ class ProjectViewSet(BaseViewSet): # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], - member=request.user, - role=20, + project_id=serializer.data["id"], member=request.user, role=20 ) # Also create the issue property for the user _ = IssueUserProperty.objects.create( - project_id=serializer.data["id"], - user=request.user, + project_id=serializer.data["id"], user=request.user ) if serializer.data["project_lead"] is not None and str( @@ -371,11 +337,7 @@ class ProjectViewSet(BaseViewSet): ] ) - project = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) - .first() - ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() # Create the model activity model_activity.delay( @@ -389,13 +351,8 @@ class ProjectViewSet(BaseViewSet): ) serializer = ProjectListSerializer(project) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -404,8 +361,7 @@ class ProjectViewSet(BaseViewSet): ) except Workspace.DoesNotExist: return Response( - {"error": "Workspace does not exist"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND ) except serializers.ValidationError: return Response( @@ -430,6 +386,7 @@ class ProjectViewSet(BaseViewSet): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) + intake_view = request.data.get("inbox_view", project.intake_view) current_instance = json.dumps( ProjectSerializer(project).data, cls=DjangoJSONEncoder ) @@ -441,21 +398,20 @@ class ProjectViewSet(BaseViewSet): serializer = ProjectSerializer( project, - data={**request.data}, + data={**request.data, "intake_view": intake_view}, context={"workspace_id": workspace.id}, partial=True, ) if serializer.is_valid(): serializer.save() - if serializer.data["inbox_view"]: - inbox = Inbox.objects.filter( - project=project, - is_default=True, + if intake_view: + intake = Intake.objects.filter( + project=project, is_default=True ).first() - if not inbox: - Inbox.objects.create( - name=f"{project.name} Inbox", + if not intake: + Intake.objects.create( + name=f"{project.name} Intake", project=project, is_default=True, ) @@ -464,17 +420,13 @@ class ProjectViewSet(BaseViewSet): State.objects.get_or_create( name="Triage", group="triage", - description="Default state for managing all Inbox Issues", + description="Default state for managing all Intake Issues", project_id=pk, color="#ff7700", is_triage=True, ) - project = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) - .first() - ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() model_activity.delay( model_name="project", @@ -487,9 +439,7 @@ class ProjectViewSet(BaseViewSet): ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): @@ -499,8 +449,7 @@ class ProjectViewSet(BaseViewSet): ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( - {"error": "Project does not exist"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) except serializers.ValidationError: return Response( @@ -511,10 +460,7 @@ class ProjectViewSet(BaseViewSet): def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( - member=request.user, - workspace__slug=slug, - is_active=True, - role=20, + member=request.user, workspace__slug=slug, is_active=True, role=20 ).exists() or ProjectMember.objects.filter( member=request.user, @@ -528,16 +474,10 @@ class ProjectViewSet(BaseViewSet): project.delete() # Delete the project members - DeployBoard.objects.filter( - project_id=pk, - workspace__slug=slug, - ).delete() + DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() # Delete the user favorite - UserFavorite.objects.filter( - project_id=pk, - workspace__slug=slug, - ).delete() + UserFavorite.objects.filter(project_id=pk, workspace__slug=slug).delete() return Response(status=status.HTTP_204_NO_CONTENT) else: @@ -548,19 +488,14 @@ class ProjectViewSet(BaseViewSet): class ProjectArchiveUnarchiveEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() project.save() - UserFavorite.objects.filter( - workspace__slug=slug, - project=project_id, - ).delete() + UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() return Response( - {"archived_at": str(project.archived_at)}, - status=status.HTTP_200_OK, + {"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -578,8 +513,7 @@ class ProjectIdentifierEndpoint(BaseAPIView): if name == "": return Response( - {"error": "Name is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) exists = ProjectIdentifier.objects.filter( @@ -587,8 +521,7 @@ class ProjectIdentifierEndpoint(BaseAPIView): ).values("id", "name", "project") return Response( - {"exists": len(exists), "identifiers": exists}, - status=status.HTTP_200_OK, + {"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @@ -597,27 +530,18 @@ class ProjectIdentifierEndpoint(BaseAPIView): if name == "": return Response( - {"error": "Name is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) - if Project.objects.filter( - identifier=name, workspace__slug=slug - ).exists(): + if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): return Response( - { - "error": "Cannot delete an identifier of an existing project" - }, + {"error": "Cannot delete an identifier of an existing project"}, status=status.HTTP_400_BAD_REQUEST, ) - ProjectIdentifier.objects.filter( - name=name, workspace__slug=slug - ).delete() + ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() - return Response( - status=status.HTTP_204_NO_CONTENT, - ) + return Response(status=status.HTTP_204_NO_CONTENT) class ProjectUserViewsEndpoint(BaseAPIView): @@ -625,15 +549,11 @@ class ProjectUserViewsEndpoint(BaseAPIView): project = Project.objects.get(pk=project_id, workspace__slug=slug) project_member = ProjectMember.objects.filter( - member=request.user, - project=project, - is_active=True, + member=request.user, project=project, is_active=True ).first() if project_member is None: - return Response( - {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN - ) + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) view_props = project_member.view_props default_props = project_member.default_props @@ -641,12 +561,8 @@ class ProjectUserViewsEndpoint(BaseAPIView): sort_order = project_member.sort_order project_member.view_props = request.data.get("view_props", view_props) - project_member.default_props = request.data.get( - "default_props", default_props - ) - project_member.preferences = request.data.get( - "preferences", preferences - ) + project_member.default_props = request.data.get("default_props", default_props) + project_member.preferences = request.data.get("preferences", preferences) project_member.sort_order = request.data.get("sort_order", sort_order) project_member.save() @@ -694,9 +610,7 @@ class ProjectFavoritesViewSet(BaseViewSet): class ProjectPublicCoverImagesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] # Cache the below api for 24 hours @cache_response(60 * 60 * 24, user=False) @@ -720,32 +634,32 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): "Prefix": "static/project-cover/", } - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) + try: + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) - return Response(files, status=status.HTTP_200_OK) + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + log_exception(e) + return Response([], status=status.HTTP_200_OK) class DeployBoardViewSet(BaseViewSet): - permission_classes = [ - ProjectMemberPermission, - ] + permission_classes = [ProjectMemberPermission] serializer_class = DeployBoardSerializer model = DeployBoard def list(self, request, slug, project_id): project_deploy_board = DeployBoard.objects.filter( - entity_name="project", - entity_identifier=project_id, - workspace__slug=slug, + entity_name="project", entity_identifier=project_id, workspace__slug=slug ).first() serializer = DeployBoardSerializer(project_deploy_board) @@ -754,7 +668,7 @@ class DeployBoardViewSet(BaseViewSet): def create(self, request, slug, project_id): comments = request.data.get("is_comments_enabled", False) reactions = request.data.get("is_reactions_enabled", False) - inbox = request.data.get("inbox", None) + intake = request.data.get("intake", None) votes = request.data.get("is_votes_enabled", False) views = request.data.get( "views", @@ -768,11 +682,9 @@ class DeployBoardViewSet(BaseViewSet): ) project_deploy_board, _ = DeployBoard.objects.get_or_create( - entity_name="project", - entity_identifier=project_id, - project_id=project_id, + entity_name="project", entity_identifier=project_id, project_id=project_id ) - project_deploy_board.inbox = inbox + project_deploy_board.intake = intake project_deploy_board.view_props = views project_deploy_board.is_votes_enabled = votes project_deploy_board.is_comments_enabled = comments diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py index b48602cec..e4d46e89f 100644 --- a/apiserver/plane/app/views/project/invite.py +++ b/apiserver/plane/app/views/project/invite.py @@ -52,24 +52,19 @@ class ProjectInvitationsViewset(BaseViewSet): # Check if email is provided if not emails: return Response( - {"error": "Emails are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST ) for email in emails: workspace_role = WorkspaceMember.objects.filter( - workspace__slug=slug, - member__email=email.get("email"), - is_active=True, + workspace__slug=slug, member__email=email.get("email"), is_active=True ).role - if workspace_role in [5, 20] and workspace_role != email.get( - "role", 5 - ): + if workspace_role in [5, 20] and workspace_role != email.get("role", 5): return Response( { "error": "You cannot invite a user with different role than workspace role" - }, + } ) workspace = Workspace.objects.get(slug=slug) @@ -84,10 +79,7 @@ class ProjectInvitationsViewset(BaseViewSet): project_id=project_id, workspace_id=workspace.id, token=jwt.encode( - { - "email": email, - "timestamp": datetime.now().timestamp(), - }, + {"email": email, "timestamp": datetime.now().timestamp()}, settings.SECRET_KEY, algorithm="HS256", ), @@ -120,10 +112,7 @@ class ProjectInvitationsViewset(BaseViewSet): ) return Response( - { - "message": "Email sent successfully", - }, - status=status.HTTP_200_OK, + {"message": "Email sent successfully"}, status=status.HTTP_200_OK ) @@ -144,19 +133,21 @@ class UserProjectInvitationsViewset(BaseViewSet): # Get the workspace user role workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, + member=request.user, workspace__slug=slug, is_active=True ) + if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]: + return Response( + {"error": "You do not have permission to join the project"}, + status=status.HTTP_403_FORBIDDEN, + ) + workspace_role = workspace_member.role workspace = workspace_member.workspace # If the user was already part of workspace _ = ProjectMember.objects.filter( - workspace__slug=slug, - project_id__in=project_ids, - member=request.user, + workspace__slug=slug, project_id__in=project_ids, member=request.user ).update(is_active=True) ProjectMember.objects.bulk_create( @@ -187,21 +178,16 @@ class UserProjectInvitationsViewset(BaseViewSet): ) return Response( - {"message": "Projects joined successfully"}, - status=status.HTTP_201_CREATED, + {"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED ) class ProjectJoinEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def post(self, request, slug, project_id, pk): project_invite = ProjectMemberInvite.objects.get( - pk=pk, - project_id=project_id, - workspace__slug=slug, + pk=pk, project_id=project_id, workspace__slug=slug ) email = request.data.get("email", "") @@ -230,11 +216,7 @@ class ProjectJoinEndpoint(BaseAPIView): _ = WorkspaceMember.objects.create( workspace_id=project_invite.workspace_id, member=user, - role=( - 15 - if project_invite.role >= 15 - else project_invite.role - ), + role=(15 if project_invite.role >= 15 else project_invite.role), ) else: # Else make him active diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index ccb5e7521..c274d87c5 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -11,7 +11,6 @@ from plane.app.serializers import ( ) from plane.app.permissions import ( - ProjectBasePermission, ProjectMemberPermission, ProjectLitePermission, WorkspaceUserPermission, @@ -20,8 +19,6 @@ from plane.app.permissions import ( from plane.db.models import ( Project, ProjectMember, - Workspace, - TeamMember, IssueUserProperty, WorkspaceMember, ) @@ -36,20 +33,13 @@ class ProjectMemberViewSet(BaseViewSet): def get_permissions(self): if self.action == "leave": - self.permission_classes = [ - ProjectLitePermission, - ] + self.permission_classes = [ProjectLitePermission] else: - self.permission_classes = [ - ProjectMemberPermission, - ] + self.permission_classes = [ProjectMemberPermission] return super(ProjectMemberViewSet, self).get_permissions() - search_fields = [ - "member__display_name", - "member__first_name", - ] + search_fields = ["member__display_name", "member__first_name"] def get_queryset(self): return self.filter_queryset( @@ -91,9 +81,7 @@ class ProjectMemberViewSet(BaseViewSet): # check the workspace role of the new user for member in member_roles: workspace_member_role = WorkspaceMember.objects.get( - workspace__slug=slug, - member=member, - is_active=True, + workspace__slug=slug, member=member, is_active=True ).role if workspace_member_role in [20] and member_roles.get(member) in [ 5, @@ -143,7 +131,6 @@ class ProjectMemberViewSet(BaseViewSet): # Loop through requested members for member in members: - # Get the sort orders of the member sort_order = [ project_member.get("sort_order") @@ -174,9 +161,7 @@ class ProjectMemberViewSet(BaseViewSet): # Bulk create the project members and issue properties project_members = ProjectMember.objects.bulk_create( - bulk_project_members, - batch_size=10, - ignore_conflicts=True, + bulk_project_members, batch_size=10, ignore_conflicts=True ) _ = IssueUserProperty.objects.bulk_create( @@ -219,10 +204,7 @@ class ProjectMemberViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN]) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( - pk=pk, - workspace__slug=slug, - project_id=project_id, - is_active=True, + pk=pk, workspace__slug=slug, project_id=project_id, is_active=True ) if request.user.id == project_member.member_id: return Response( @@ -238,9 +220,7 @@ class ProjectMemberViewSet(BaseViewSet): ) workspace_role = WorkspaceMember.objects.get( - workspace__slug=slug, - member=project_member.member, - is_active=True, + workspace__slug=slug, member=project_member.member, is_active=True ).role if workspace_role in [5] and int( request.data.get("role", project_member.role) @@ -332,7 +312,7 @@ class ProjectMemberViewSet(BaseViewSet): ): return Response( { - "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin", + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin" }, status=status.HTTP_400_BAD_REQUEST, ) @@ -342,56 +322,6 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class AddTeamToProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def post(self, request, slug, project_id): - team_members = TeamMember.objects.filter( - workspace__slug=slug, team__in=request.data.get("teams", []) - ).values_list("member", flat=True) - - if len(team_members) == 0: - return Response( - {"error": "No such team exists"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - project_members = [] - issue_props = [] - for member in team_members: - project_members.append( - ProjectMember( - project_id=project_id, - member_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - issue_props.append( - IssueUserProperty( - project_id=project_id, - user_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - - ProjectMember.objects.bulk_create( - project_members, batch_size=10, ignore_conflicts=True - ) - - _ = IssueUserProperty.objects.bulk_create( - issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - class ProjectMemberUserEndpoint(BaseAPIView): def get(self, request, slug, project_id): project_member = ProjectMember.objects.get( @@ -406,15 +336,11 @@ class ProjectMemberUserEndpoint(BaseAPIView): class UserProjectRolesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceUserPermission, - ] + permission_classes = [WorkspaceUserPermission] def get(self, request, slug): project_members = ProjectMember.objects.filter( - workspace__slug=slug, - member_id=request.user.id, - is_active=True, + workspace__slug=slug, member_id=request.user.id, is_active=True ).values("project_id", "role") project_members = { diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 714ccacc6..5161103f5 100644 --- a/apiserver/plane/app/views/search/base.py +++ b/apiserver/plane/app/views/search/base.py @@ -36,9 +36,7 @@ class GlobalSearchEndpoint(BaseAPIView): for field in fields: q |= Q(**{f"{field}__icontains": query}) return ( - Workspace.objects.filter( - q, workspace_member__member=self.request.user - ) + Workspace.objects.filter(q, workspace_member__member=self.request.user) .distinct() .values("name", "id", "slug") ) @@ -110,11 +108,7 @@ class GlobalSearchEndpoint(BaseAPIView): cycles = cycles.filter(project_id=project_id) return cycles.distinct().values( - "name", - "id", - "project_id", - "project__identifier", - "workspace__slug", + "name", "id", "project_id", "project__identifier", "workspace__slug" ) def filter_modules(self, query, slug, project_id, workspace_search): @@ -135,11 +129,7 @@ class GlobalSearchEndpoint(BaseAPIView): modules = modules.filter(project_id=project_id) return modules.distinct().values( - "name", - "id", - "project_id", - "project__identifier", - "workspace__slug", + "name", "id", "project_id", "project__identifier", "workspace__slug" ) def filter_pages(self, query, slug, project_id, workspace_search): @@ -159,12 +149,10 @@ class GlobalSearchEndpoint(BaseAPIView): .annotate( project_ids=Coalesce( ArrayAgg( - "projects__id", - distinct=True, - filter=~Q(projects__id=True), + "projects__id", distinct=True, filter=~Q(projects__id=True) ), Value([], output_field=ArrayField(UUIDField())), - ), + ) ) .annotate( project_identifiers=Coalesce( @@ -174,26 +162,21 @@ class GlobalSearchEndpoint(BaseAPIView): filter=~Q(projects__id=True), ), Value([], output_field=ArrayField(CharField())), - ), + ) ) ) if workspace_search == "false" and project_id: project_subquery = ProjectPage.objects.filter( - page_id=OuterRef("id"), - project_id=project_id, + 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) + pages = pages.annotate(project_id=Subquery(project_subquery)).filter( + project_id=project_id + ) return pages.distinct().values( - "name", - "id", - "project_ids", - "project_identifiers", - "workspace__slug", + "name", "id", "project_ids", "project_identifiers", "workspace__slug" ) def filter_views(self, query, slug, project_id, workspace_search): @@ -214,18 +197,12 @@ class GlobalSearchEndpoint(BaseAPIView): issue_views = issue_views.filter(project_id=project_id) return issue_views.distinct().values( - "name", - "id", - "project_id", - "project__identifier", - "workspace__slug", + "name", "id", "project_id", "project__identifier", "workspace__slug" ) def get(self, request, slug): query = request.query_params.get("search", False) - workspace_search = request.query_params.get( - "workspace_search", "false" - ) + workspace_search = request.query_params.get("workspace_search", "false") project_id = request.query_params.get("project_id", False) if not query: diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py index 9be1d25d9..13fdc4eff 100644 --- a/apiserver/plane/app/views/search/issue.py +++ b/apiserver/plane/app/views/search/issue.py @@ -16,9 +16,7 @@ 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" - ) + 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") @@ -42,47 +40,50 @@ class IssueSearchEndpoint(BaseAPIView): 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) - ) + issue = Issue.issue_objects.filter(pk=issue_id).first() + if issue: + 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, - issue_related__deleted_at__isnull=True, - ), - ~Q( - issue_relation__related_issue=issue, - issue_related__deleted_at__isnull=True, - ), - ) + issue = Issue.issue_objects.filter(pk=issue_id).first() + if issue: + issues = issues.filter( + ~Q(pk=issue_id), + ~( + Q(issue_related__issue=issue) + & Q(issue_related__deleted_at__isnull=True) + ), + ~( + Q(issue_relation__related_issue=issue) + & Q(issue_relation__deleted_at__isnull=True) + ), + ) 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) + issue = Issue.issue_objects.filter(pk=issue_id).first() + if issue: + 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) + issues = issues.exclude( + Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True) + ) if module: - issues = issues.exclude(issue_module__module=module) + issues = issues.exclude( + Q(issue_module__module=module) + & Q(issue_module__deleted_at__isnull=True) + ) if target_date == "none": issues = issues.filter(target_date__isnull=True) - + if ProjectMember.objects.filter( - project_id=project_id, - member=self.request.user, - is_active=True, - role=5 + project_id=project_id, member=self.request.user, is_active=True, role=5 ).exists(): - issues = issues.filter( - created_by=self.request.user - ) + issues = issues.filter(created_by=self.request.user) return Response( issues.values( diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index fd94a8dae..00f4813e6 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -8,10 +8,7 @@ from rest_framework import status # Module imports from .. import BaseViewSet from plane.app.serializers import StateSerializer -from plane.app.permissions import ( - ROLE, - allow_permission -) +from plane.app.permissions import ROLE, allow_permission from plane.db.models import State, Issue from plane.utils.cache import invalidate_cache @@ -37,9 +34,7 @@ class StateViewSet(BaseViewSet): .distinct() ) - @invalidate_cache( - path="workspaces/:slug/states/", url_params=True, user=False - ) + @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): serializer = StateSerializer(data=request.data) @@ -62,9 +57,7 @@ class StateViewSet(BaseViewSet): return Response(state_dict, status=status.HTTP_200_OK) return Response(states, status=status.HTTP_200_OK) - @invalidate_cache( - path="workspaces/:slug/states/", url_params=True, user=False - ) + @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def mark_as_default(self, request, slug, project_id, pk): # Select all the states which are marked as default @@ -76,16 +69,11 @@ class StateViewSet(BaseViewSet): ).update(default=True) return Response(status=status.HTTP_204_NO_CONTENT) - @invalidate_cache( - path="workspaces/:slug/states/", url_params=True, user=False - ) + @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def destroy(self, request, slug, project_id, pk): state = State.objects.get( - is_triage=False, - pk=pk, - project_id=project_id, - workspace__slug=slug, + is_triage=False, pk=pk, project_id=project_id, workspace__slug=slug ) if state.default: @@ -99,9 +87,7 @@ class StateViewSet(BaseViewSet): if issue_exist: return Response( - { - "error": "The state is not empty, only empty states can be deleted" - }, + {"error": "The state is not empty, only empty states can be deleted"}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 27bfd3b7a..4eca872f3 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -32,7 +32,6 @@ from plane.db.models import ( Session, ) 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 @@ -49,46 +48,28 @@ class UserEndpoint(BaseViewSet): def get_object(self): return self.request.user - @cache_response(60 * 60) @method_decorator(cache_control(private=True, max_age=12)) @method_decorator(vary_on_cookie) def retrieve(self, request): serialized_data = UserMeSerializer(request.user).data - return Response( - serialized_data, - status=status.HTTP_200_OK, - ) + return Response(serialized_data, status=status.HTTP_200_OK) - @cache_response(60 * 60) @method_decorator(cache_control(private=True, max_age=12)) @method_decorator(vary_on_cookie) def retrieve_user_settings(self, request): serialized_data = UserMeSettingsSerializer(request.user).data return Response(serialized_data, status=status.HTTP_200_OK) - @cache_response(60 * 60) def retrieve_instance_admin(self, request): instance = Instance.objects.first() is_admin = InstanceAdmin.objects.filter( instance=instance, user=request.user ).exists() - return Response( - {"is_instance_admin": is_admin}, status=status.HTTP_200_OK - ) + return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) - @invalidate_cache( - path="/api/users/me/", - ) - @invalidate_cache( - path="/api/users/me/settings/", - ) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) - @invalidate_cache(path="/api/users/me/") - @invalidate_cache( - path="/api/users/me/workspaces/", multiple=True, user=False - ) def deactivate(self, request): # Check all workspace user is active user = self.get_object() @@ -110,10 +91,7 @@ class UserEndpoint(BaseViewSet): ).annotate( other_admin_exists=Count( Case( - When( - Q(role=20, is_active=True) & ~Q(member=request.user), - then=1, - ), + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), default=0, output_field=IntegerField(), ) @@ -138,10 +116,7 @@ class UserEndpoint(BaseViewSet): ).annotate( other_admin_exists=Count( Case( - When( - Q(role=20, is_active=True) & ~Q(member=request.user), - then=1, - ), + When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), default=0, output_field=IntegerField(), ) @@ -150,9 +125,7 @@ class UserEndpoint(BaseViewSet): ) for workspace in workspaces: - if workspace.other_admin_exists > 0 or ( - workspace.total_members == 1 - ): + if workspace.other_admin_exists > 0 or (workspace.total_members == 1): workspace.is_active = False workspaces_to_deactivate.append(workspace) else: @@ -172,9 +145,7 @@ class UserEndpoint(BaseViewSet): ) # Delete all workspace invites - WorkspaceMemberInvite.objects.filter( - email=user.email, - ).delete() + WorkspaceMemberInvite.objects.filter(email=user.email).delete() # Delete all sessions Session.objects.filter(user_id=request.user.id).delete() @@ -205,9 +176,7 @@ class UserEndpoint(BaseViewSet): user.save() # Send an email to the user - user_deactivation_email.delay( - base_host(request=request, is_app=True), user.id - ) + user_deactivation_email.delay(base_host(request=request, is_app=True), user.id) # Logout the user logout(request) @@ -215,10 +184,7 @@ class UserEndpoint(BaseViewSet): class UserSessionEndpoint(BaseAPIView): - - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request): if request.user.is_authenticated: @@ -228,43 +194,30 @@ class UserSessionEndpoint(BaseAPIView): data["user"] = serializer.data return Response(data, status=status.HTTP_200_OK) else: - return Response( - {"is_authenticated": False}, status=status.HTTP_200_OK - ) + return Response({"is_authenticated": False}, status=status.HTTP_200_OK) class UpdateUserOnBoardedEndpoint(BaseAPIView): - - @invalidate_cache(path="/api/users/me/") def patch(self, request): profile = Profile.objects.get(user_id=request.user.id) profile.is_onboarded = request.data.get("is_onboarded", False) profile.save() - return Response( - {"message": "Updated successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) class UpdateUserTourCompletedEndpoint(BaseAPIView): - - @invalidate_cache(path="/api/users/me/") def patch(self, request): profile = Profile.objects.get(user_id=request.user.id) - profile.is_tour_completed = request.data.get( - "is_tour_completed", False - ) + profile.is_tour_completed = request.data.get("is_tour_completed", False) profile.save() - return Response( - {"message": "Updated successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) class UserActivityEndpoint(BaseAPIView, BasePaginator): - def get(self, request): - queryset = IssueActivity.objects.filter( - actor=request.user - ).select_related("actor", "workspace", "issue", "project") + queryset = IssueActivity.objects.filter(actor=request.user).select_related( + "actor", "workspace", "issue", "project" + ) return self.paginate( order_by=request.GET.get("order_by", "-created_at"), @@ -277,7 +230,6 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): class AccountEndpoint(BaseAPIView): - def get(self, request, pk=None): if pk: account = Account.objects.get(pk=pk, user=request.user) @@ -286,10 +238,7 @@ class AccountEndpoint(BaseAPIView): account = Account.objects.filter(user=request.user) serializer = AccountSerializer(account, many=True) - return Response( - serializer.data, - status=status.HTTP_200_OK, - ) + return Response(serializer.data, status=status.HTTP_200_OK) def delete(self, request, pk): account = Account.objects.get(pk=pk, user=request.user) @@ -305,12 +254,9 @@ class ProfileEndpoint(BaseAPIView): serializer = ProfileSerializer(profile) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache("/api/users/me/settings/") def patch(self, request): profile = Profile.objects.get(user=request.user) - serializer = ProfileSerializer( - profile, data=request.data, partial=True - ) + serializer = ProfileSerializer(profile, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 861aa4292..ca4048b70 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,15 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import ( - Exists, - F, - Func, - OuterRef, - Q, - UUIDField, - Value, -) +from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery from django.db.models.functions import Coalesce from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -20,22 +12,18 @@ from rest_framework import status from rest_framework.response import Response # Module imports -from plane.app.permissions import ( - allow_permission, - ROLE, -) -from plane.app.serializers import ( - IssueViewSerializer, -) +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import IssueViewSerializer from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueView, Workspace, WorkspaceMember, ProjectMember, Project, + CycleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -44,15 +32,10 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.bgtasks.recent_visited_task import recent_visited_task from .. import BaseViewSet -from plane.db.models import ( - UserFavorite, -) +from plane.db.models import UserFavorite class WorkspaceViewViewSet(BaseViewSet): @@ -76,21 +59,13 @@ class WorkspaceViewViewSet(BaseViewSet): ) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): queryset = self.get_queryset() - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + fields = [field for field in request.GET.get("fields", "").split(",") if field] if WorkspaceMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=5, - is_active=True, + workspace__slug=slug, member=request.user, role=5, is_active=True ).exists(): queryset = queryset.filter(owned_by=request.user) views = IssueViewSerializer( @@ -104,22 +79,18 @@ class WorkspaceViewViewSet(BaseViewSet): def partial_update(self, request, slug, pk): with transaction.atomic(): workspace_view = IssueView.objects.select_for_update().get( - pk=pk, - workspace__slug=slug, + pk=pk, workspace__slug=slug ) if workspace_view.is_locked: return Response( - {"error": "view is locked"}, - status=status.HTTP_400_BAD_REQUEST, + {"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" - }, + {"error": "Only the owner of the view can update the view"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -130,9 +101,7 @@ class WorkspaceViewViewSet(BaseViewSet): 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 - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, pk): issue_view = self.get_queryset().filter(pk=pk).first() @@ -144,33 +113,18 @@ class WorkspaceViewViewSet(BaseViewSet): entity_identifier=pk, user_id=request.user.id, ) - return Response( - serializer.data, - status=status.HTTP_200_OK, - ) + return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission( - allowed_roles=[], - level="WORKSPACE", - creator=True, - model=IssueView, + allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView ) def destroy(self, request, slug, pk): - workspace_view = IssueView.objects.get( - pk=pk, - workspace__slug=slug, - ) + 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, + workspace__slug=slug, member=request.user, role=20, is_active=True ) - if ( - workspace_member.exists() - or workspace_view.owned_by == request.user - ): + if workspace_member.exists() or workspace_view.owned_by == request.user: workspace_view.delete() # Delete the user favorite view UserFavorite.objects.filter( @@ -191,9 +145,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -205,7 +157,13 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -213,17 +171,16 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -233,7 +190,10 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -241,8 +201,11 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -250,8 +213,11 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + filter=Q( + ~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -260,8 +226,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): @method_decorator(gzip_page) @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], - level="WORKSPACE", + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") @@ -270,7 +235,13 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) ) # check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user @@ -294,8 +265,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -304,9 +274,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -326,16 +294,11 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): 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, + 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, + field=group_by, slug=slug, project_id=None, filters=filters ), sub_group_by_fields=issue_group_values( field=sub_group_by, @@ -346,10 +309,10 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -362,23 +325,18 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): 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, + 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, + 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -401,8 +359,7 @@ class IssueViewViewSet(BaseViewSet): def perform_create(self, serializer): serializer.save( - project_id=self.kwargs.get("project_id"), - owned_by=self.request.user, + project_id=self.kwargs.get("project_id"), owned_by=self.request.user ) def get_queryset(self): @@ -431,8 +388,7 @@ class IssueViewViewSet(BaseViewSet): .distinct() ) - allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(id=project_id) @@ -447,22 +403,15 @@ class IssueViewViewSet(BaseViewSet): and not project.guest_view_all_features ): queryset = queryset.filter(owned_by=request.user) - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + fields = [field for field in request.GET.get("fields", "").split(",") if field] views = IssueViewSerializer( queryset, many=True, fields=fields if fields else None ).data return Response(views, status=status.HTTP_200_OK) - allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def retrieve(self, request, slug, project_id, pk): - issue_view = ( - self.get_queryset().filter(pk=pk, project_id=project_id).first() - ) + issue_view = self.get_queryset().filter(pk=pk, project_id=project_id).first() project = Project.objects.get(id=project_id) """ if the role is guest and guest_view_all_features is false and owned by is not @@ -493,13 +442,9 @@ class IssueViewViewSet(BaseViewSet): entity_identifier=pk, user_id=request.user.id, ) - return Response( - serializer.data, - status=status.HTTP_200_OK, - ) - - allow_permission(allowed_roles=[], creator=True, model=IssueView) + return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission(allowed_roles=[], creator=True, model=IssueView) def partial_update(self, request, slug, project_id, pk): with transaction.atomic(): issue_view = IssueView.objects.select_for_update().get( @@ -508,16 +453,13 @@ class IssueViewViewSet(BaseViewSet): if issue_view.is_locked: return Response( - {"error": "view is locked"}, - status=status.HTTP_400_BAD_REQUEST, + {"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" - }, + {"error": "Only the owner of the view can update the view"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -528,17 +470,12 @@ class IssueViewViewSet(BaseViewSet): 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 - ) - - allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) def destroy(self, request, slug, project_id, pk): project_view = IssueView.objects.get( - pk=pk, - project_id=project_id, - workspace__slug=slug, + pk=pk, project_id=project_id, workspace__slug=slug ) if ( ProjectMember.objects.filter( @@ -578,8 +515,7 @@ class IssueViewFavoriteViewSet(BaseViewSet): .select_related("view") ) - allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): _ = UserFavorite.objects.create( user=request.user, @@ -589,8 +525,7 @@ class IssueViewFavoriteViewSet(BaseViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) - allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, view_id): view_favorite = UserFavorite.objects.get( project=project_id, diff --git a/apiserver/plane/app/views/webhook/base.py b/apiserver/plane/app/views/webhook/base.py index 5581b6aa3..d62838fab 100644 --- a/apiserver/plane/app/views/webhook/base.py +++ b/apiserver/plane/app/views/webhook/base.py @@ -14,7 +14,6 @@ from plane.app.serializers import WebhookSerializer, WebhookLogSerializer class WebhookEndpoint(BaseAPIView): - @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) @@ -24,12 +23,8 @@ class WebhookEndpoint(BaseAPIView): ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -112,7 +107,6 @@ class WebhookEndpoint(BaseAPIView): class WebhookSecretRegenerateEndpoint(BaseAPIView): - @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def post(self, request, slug, pk): webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) @@ -123,7 +117,6 @@ class WebhookSecretRegenerateEndpoint(BaseAPIView): class WebhookLogsEndpoint(BaseAPIView): - @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug, webhook_id): webhook_logs = WebhookLog.objects.filter( diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index dd6417a05..515a3479b 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -1,18 +1,12 @@ # Python imports import csv import io +import os from datetime import date from dateutil.relativedelta import relativedelta from django.db import IntegrityError -from django.db.models import ( - Count, - F, - Func, - OuterRef, - Prefetch, - Q, -) +from django.db.models import Count, F, Func, OuterRef, Prefetch, Q from django.db.models.fields import DateField from django.db.models.functions import Cast, ExtractDay, ExtractWeek @@ -31,10 +25,7 @@ from plane.app.permissions import ( ) # Module imports -from plane.app.serializers import ( - WorkSpaceSerializer, - WorkspaceThemeSerializer, -) +from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer from plane.app.views.base import BaseAPIView, BaseViewSet from plane.db.models import ( Issue, @@ -44,35 +35,26 @@ from plane.db.models import ( WorkspaceTheme, ) from plane.app.permissions import ROLE, allow_permission -from plane.utils.cache import cache_response, invalidate_cache from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from django.views.decorators.vary import vary_on_cookie from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS - +from plane.license.utils.instance_value import get_configuration_value class WorkSpaceViewSet(BaseViewSet): model = Workspace serializer_class = WorkSpaceSerializer - permission_classes = [ - WorkSpaceBasePermission, - ] + permission_classes = [WorkSpaceBasePermission] - search_fields = [ - "name", - ] - filterset_fields = [ - "owner", - ] + search_fields = ["name"] + filterset_fields = ["owner"] lookup_field = "slug" def get_queryset(self): member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), - member__is_bot=False, - is_active=True, + workspace=OuterRef("id"), member__is_bot=False, is_active=True ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -86,9 +68,7 @@ class WorkSpaceViewSet(BaseViewSet): .values("count") ) return ( - self.filter_queryset( - super().get_queryset().select_related("owner") - ) + self.filter_queryset(super().get_queryset().select_related("owner")) .order_by("name") .filter( workspace_member__member=self.request.user, @@ -99,11 +79,23 @@ class WorkSpaceViewSet(BaseViewSet): .select_related("owner") ) - @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/") - @invalidate_cache(path="/api/instances/", user=False) def create(self, request): try: + DISABLE_WORKSPACE_CREATION, = get_configuration_value( + [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + }, + ] + ) + + if DISABLE_WORKSPACE_CREATION == "1": + return Response( + {"error": "Workspace creation is not allowed"}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = WorkSpaceSerializer(data=request.data) slug = request.data.get("slug", False) @@ -117,9 +109,7 @@ class WorkSpaceViewSet(BaseViewSet): if len(name) > 80 or len(slug) > 48: return Response( - { - "error": "The maximum length for name is 80 and for slug is 48" - }, + {"error": "The maximum length for name is 80 and for slug is 48"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -132,9 +122,7 @@ class WorkSpaceViewSet(BaseViewSet): role=20, company_role=request.data.get("company_role", ""), ) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], status=status.HTTP_400_BAD_REQUEST, @@ -147,63 +135,30 @@ class WorkSpaceViewSet(BaseViewSet): status=status.HTTP_410_GONE, ) - @cache_response(60 * 60 * 2) - @allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ROLE.GUEST, - ], - level="WORKSPACE", - ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/") - @allow_permission( - [ - ROLE.ADMIN, - ], - level="WORKSPACE", - ) + @allow_permission([ROLE.ADMIN], level="WORKSPACE") def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) - @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache( - path="/api/users/me/workspaces/", multiple=True, user=False - ) - @invalidate_cache( - path="/api/users/me/settings/", multiple=True, user=False - ) @allow_permission([ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) class UserWorkSpacesEndpoint(BaseAPIView): - search_fields = [ - "name", - ] - filterset_fields = [ - "owner", - ] + search_fields = ["name"] + filterset_fields = ["owner"] - @cache_response(60 * 60 * 2) @method_decorator(cache_control(private=True, max_age=12)) @method_decorator(vary_on_cookie) def get(self, request): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + fields = [field for field in request.GET.get("fields", "").split(",") if field] member_count = ( WorkspaceMember.objects.filter( - workspace=OuterRef("id"), - member__is_bot=False, - is_active=True, + workspace=OuterRef("id"), member__is_bot=False, is_active=True ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -230,8 +185,7 @@ class UserWorkSpacesEndpoint(BaseAPIView): .annotate(total_members=member_count) .annotate(total_issues=issue_count) .filter( - workspace_member__member=request.user, - workspace_member__is_active=True, + workspace_member__member=request.user, workspace_member__is_active=True ) .distinct() ) @@ -306,15 +260,12 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): ).count() completed_issues_count = Issue.issue_objects.filter( - workspace__slug=slug, - assignees__in=[request.user], - state__group="completed", + workspace__slug=slug, assignees__in=[request.user], state__group="completed" ).count() issues_due_week = ( Issue.issue_objects.filter( - workspace__slug=slug, - assignees__in=[request.user], + workspace__slug=slug, assignees__in=[request.user] ) .annotate(target_week=ExtractWeek("target_date")) .filter(target_week=timezone.now().date().isocalendar()[1]) @@ -364,18 +315,12 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): class WorkspaceThemeViewSet(BaseViewSet): - permission_classes = [ - WorkSpaceAdminPermission, - ] + permission_classes = [WorkSpaceAdminPermission] model = WorkspaceTheme serializer_class = WorkspaceThemeSerializer def get_queryset(self): - return ( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - ) + return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) @@ -387,9 +332,7 @@ class WorkspaceThemeViewSet(BaseViewSet): class ExportWorkspaceUserActivityEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + permission_classes = [WorkspaceEntityPermission] def generate_csv_from_rows(self, rows): """Generate CSV buffer from rows.""" @@ -400,11 +343,9 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): return csv_buffer def post(self, request, slug, user_id): - if not request.data.get("date"): return Response( - {"error": "Date is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Date is required"}, status=status.HTTP_400_BAD_REQUEST ) user_activities = IssueActivity.objects.filter( @@ -412,6 +353,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): workspace__slug=slug, created_at__date=request.data.get("date"), project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, actor_id=user_id, ).select_related("actor", "workspace", "issue", "project")[:10000] diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index f642416e3..ec08f47c9 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -1,8 +1,5 @@ # Django imports -from django.db.models import ( - Q, - Count, -) +from django.db.models import Q, Count # Third party modules from rest_framework import status @@ -16,9 +13,7 @@ from plane.app.serializers.cycle import CycleSerializer class WorkspaceCyclesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] + permission_classes = [WorkspaceViewerPermission] def get(self, request, slug): cycles = ( @@ -33,6 +28,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -43,6 +39,8 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -53,6 +51,8 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -63,6 +63,8 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -73,6 +75,8 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) @@ -83,6 +87,8 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, + issue_cycle__deleted_at__isnull=True, ), ) ) diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py new file mode 100644 index 000000000..fa161cbab --- /dev/null +++ b/apiserver/plane/app/views/workspace/draft.py @@ -0,0 +1,321 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core import serializers +from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value, Subquery, OuterRef +from django.db.models.functions import Coalesce +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + IssueCreateSerializer, + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) +from plane.db.models import ( + Issue, + DraftIssue, + CycleIssue, + ModuleIssue, + DraftIssueCycle, + Workspace, + FileAsset, +) +from .. import BaseViewSet +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.issue_filters import issue_filters + + +class WorkspaceDraftIssueViewSet(BaseViewSet): + model = DraftIssue + + def get_queryset(self): + return ( + DraftIssue.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "draft_issue_module__module") + .annotate( + cycle_id=Subquery( + DraftIssueCycle.objects.filter( + draft_issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=Q( + ~Q(labels__id__isnull=True) + & (Q(draft_label_issue__deleted_at__isnull=True)) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(draft_issue_assignee__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "draft_issue_module__module_id", + distinct=True, + filter=Q( + ~Q(draft_issue_module__module_id__isnull=True) + & Q(draft_issue_module__module__archived_at__isnull=True) + & Q(draft_issue_module__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issues = ( + self.get_queryset().filter(created_by=request.user).order_by("-created_at") + ) + + issues = issues.filter(**filters) + # List Paginate + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: DraftIssueSerializer(issues, many=True).data, + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = DraftIssueCreateSerializer( + data=request.data, + context={ + "workspace_id": workspace.id, + "project_id": request.data.get("project_id", None), + }, + ) + if serializer.is_valid(): + serializer.save() + issue = ( + self.get_queryset() + .filter(pk=serializer.data.get("id")) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + "type_id", + "description_html", + ) + .first() + ) + + return Response(issue, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def partial_update(self, request, slug, pk): + issue = self.get_queryset().filter(pk=pk, created_by=request.user).first() + + if not issue: + return Response( + {"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = DraftIssueCreateSerializer( + issue, + data=request.data, + partial=True, + context={ + "project_id": request.data.get("project_id", None), + "cycle_id": request.data.get("cycle_id", "not_provided"), + }, + ) + + if serializer.is_valid(): + serializer.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], creator=True, model=Issue, level="WORKSPACE" + ) + def retrieve(self, request, slug, pk=None): + issue = self.get_queryset().filter(pk=pk, created_by=request.user).first() + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = DraftIssueDetailSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], creator=True, model=DraftIssue, level="WORKSPACE" + ) + def destroy(self, request, slug, pk=None): + draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk) + draft_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def create_draft_to_issue(self, request, slug, draft_id): + draft_issue = self.get_queryset().filter(pk=draft_id).first() + + if not draft_issue.project_id: + return Response( + {"error": "Project is required to create an issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": draft_issue.project_id, + "workspace_id": draft_issue.project.workspace_id, + "default_assignee_id": draft_issue.project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save() + + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(draft_issue.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if request.data.get("cycle_id", None): + created_records = CycleIssue.objects.create( + cycle_id=request.data.get("cycle_id", None), + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + workspace_id=draft_issue.workspace_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": None, + "created_cycle_issues": serializers.serialize( + "json", [created_records] + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if request.data.get("module_ids", []): + # bulk create the module + ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + module_id=module, + issue_id=serializer.data.get("id", None), + workspace_id=draft_issue.workspace_id, + project_id=draft_issue.project_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + for module in request.data.get("module_ids", []) + ], + batch_size=10, + ) + # Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(request.user.id), + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in request.data.get("module_ids", []) + ] + + # Update file assets + file_assets = FileAsset.objects.filter(draft_issue_id=draft_id) + file_assets.update( + issue_id=serializer.data.get("id", None), + entity_type=FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + draft_issue_id=None, + ) + + # delete the draft issue + draft_issue.delete() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/estimate.py b/apiserver/plane/app/views/workspace/estimate.py index 59a23d867..beef2a8ec 100644 --- a/apiserver/plane/app/views/workspace/estimate.py +++ b/apiserver/plane/app/views/workspace/estimate.py @@ -11,9 +11,7 @@ from plane.utils.cache import cache_response class WorkspaceEstimatesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + permission_classes = [WorkspaceEntityPermission] @cache_response(60 * 60 * 2) def get(self, request, slug): diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apiserver/plane/app/views/workspace/favorite.py index 25c76779c..38fa1bdef 100644 --- a/apiserver/plane/app/views/workspace/favorite.py +++ b/apiserver/plane/app/views/workspace/favorite.py @@ -13,16 +13,11 @@ from plane.app.permissions import allow_permission, ROLE class WorkspaceFavoriteEndpoint(BaseAPIView): - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): # the second filter is to check if the user is a member of the project favorites = UserFavorite.objects.filter( - user=request.user, - workspace__slug=slug, - parent__isnull=True, + user=request.user, workspace__slug=slug, parent__isnull=True ).filter( Q(project__isnull=True) & ~Q(entity_type="page") | ( @@ -34,9 +29,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): serializer = UserFavoriteSerializer(favorites, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) serializer = UserFavoriteSerializer(data=request.data) @@ -49,24 +42,18 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def patch(self, request, slug, favorite_id): favorite = UserFavorite.objects.get( user=request.user, workspace__slug=slug, pk=favorite_id ) - serializer = UserFavoriteSerializer( - favorite, data=request.data, partial=True - ) + serializer = UserFavoriteSerializer(favorite, 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) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def delete(self, request, slug, favorite_id): favorite = UserFavorite.objects.get( user=request.user, workspace__slug=slug, pk=favorite_id @@ -76,15 +63,10 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): class WorkspaceFavoriteGroupEndpoint(BaseAPIView): - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, favorite_id): favorites = UserFavorite.objects.filter( - user=request.user, - workspace__slug=slug, - parent_id=favorite_id, + user=request.user, workspace__slug=slug, parent_id=favorite_id ).filter( Q(project__isnull=True) | ( diff --git a/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py index 79b03d8a0..486a3c93b 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apiserver/plane/app/views/workspace/invite.py @@ -24,12 +24,7 @@ from plane.app.serializers import ( from plane.app.views.base import BaseAPIView from plane.bgtasks.event_tracking_task import workspace_invite_event from plane.bgtasks.workspace_invitation_task import workspace_invitation -from plane.db.models import ( - User, - Workspace, - WorkspaceMember, - WorkspaceMemberInvite, -) +from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly from .. import BaseViewSet @@ -41,9 +36,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): serializer_class = WorkSpaceMemberInviteSerializer model = WorkspaceMemberInvite - permission_classes = [ - WorkSpaceAdminPermission, - ] + permission_classes = [WorkSpaceAdminPermission] def get_queryset(self): return self.filter_queryset( @@ -58,15 +51,12 @@ class WorkspaceInvitationsViewset(BaseViewSet): # Check if email is provided if not emails: return Response( - {"error": "Emails are required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST ) # check for role level of the requesting user requesting_user = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, + workspace__slug=slug, member=request.user, is_active=True ) # Check if any invited user has an higher role @@ -112,10 +102,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): email=email.get("email").strip().lower(), workspace_id=workspace.id, token=jwt.encode( - { - "email": email, - "timestamp": datetime.now().timestamp(), - }, + {"email": email, "timestamp": datetime.now().timestamp()}, settings.SECRET_KEY, algorithm="HS256", ), @@ -148,10 +135,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): ) return Response( - { - "message": "Emails sent successfully", - }, - status=status.HTTP_200_OK, + {"message": "Emails sent successfully"}, status=status.HTTP_200_OK ) def destroy(self, request, slug, pk): @@ -163,9 +147,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): class WorkspaceJoinEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] """Invitation response endpoint the user can respond to the invitation""" @invalidate_cache(path="/api/workspaces/", user=False) diff --git a/apiserver/plane/app/views/workspace/label.py b/apiserver/plane/app/views/workspace/label.py index 0ea9ea24a..c93cd44c8 100644 --- a/apiserver/plane/app/views/workspace/label.py +++ b/apiserver/plane/app/views/workspace/label.py @@ -9,11 +9,10 @@ from plane.db.models import Label from plane.app.permissions import WorkspaceViewerPermission from plane.utils.cache import cache_response + class WorkspaceLabelsEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] - + permission_classes = [WorkspaceViewerPermission] + @cache_response(60 * 60 * 2) def get(self, request, slug): labels = Label.objects.filter( diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 0a2f1539f..91a89ad07 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -1,17 +1,18 @@ # Django imports from django.db.models import ( - CharField, Count, Q, + OuterRef, + Subquery, + IntegerField, ) -from django.db.models.functions import Cast +from django.db.models.functions import Coalesce # Third party modules from rest_framework import status from rest_framework.response import Response from plane.app.permissions import ( - WorkSpaceAdminPermission, WorkspaceEntityPermission, allow_permission, ROLE, @@ -20,8 +21,6 @@ from plane.app.permissions import ( # Module imports from plane.app.serializers import ( ProjectMemberRoleSerializer, - TeamSerializer, - UserLiteSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, WorkSpaceMemberSerializer, @@ -30,12 +29,10 @@ from plane.app.views.base import BaseAPIView from plane.db.models import ( Project, ProjectMember, - Team, - User, - Workspace, WorkspaceMember, + DraftIssue, ) -from plane.utils.cache import cache_response, invalidate_cache +from plane.utils.cache import invalidate_cache from .. import BaseViewSet @@ -44,63 +41,41 @@ class WorkSpaceMemberViewSet(BaseViewSet): serializer_class = WorkspaceMemberAdminSerializer model = WorkspaceMember - search_fields = [ - "member__display_name", - "member__first_name", - ] + search_fields = ["member__display_name", "member__first_name"] def get_queryset(self): return self.filter_queryset( super() .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ) + .filter(workspace__slug=self.kwargs.get("slug"), is_active=True) .select_related("workspace", "workspace__owner") .select_related("member") ) - @cache_response(60 * 60 * 2) @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) def list(self, request, slug): workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, + member=request.user, workspace__slug=slug, is_active=True ) # Get all active workspace members workspace_members = self.get_queryset() if workspace_member.role > 5: serializer = WorkspaceMemberAdminSerializer( - workspace_members, - fields=("id", "member", "role"), - many=True, + workspace_members, fields=("id", "member", "role"), many=True ) else: serializer = WorkSpaceMemberSerializer( - workspace_members, - fields=("id", "member", "role"), - many=True, + workspace_members, fields=("id", "member", "role"), many=True ) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache( - path="/api/workspaces/:slug/members/", - url_params=True, - user=False, - multiple=True, - ) @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get( - pk=pk, - workspace__slug=slug, - member__is_bot=False, - is_active=True, + pk=pk, workspace__slug=slug, member__is_bot=False, is_active=True ) if request.user.id == workspace_member.member_id: return Response( @@ -122,31 +97,16 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @invalidate_cache( - path="/api/workspaces/:slug/members/", - url_params=True, - user=False, - multiple=True, - ) - @invalidate_cache(path="/api/users/me/settings/", multiple=True) - @invalidate_cache( - path="/api/users/me/workspaces/", user=False, multiple=True - ) @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, slug, pk): # Check the user role who is deleting the user workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - pk=pk, - member__is_bot=False, - is_active=True, + workspace__slug=slug, pk=pk, member__is_bot=False, is_active=True ) # check requesting user role requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, + workspace__slug=slug, member=request.user, is_active=True ) if str(workspace_member.id) == str(requesting_workspace_member.id): @@ -212,18 +172,14 @@ class WorkSpaceMemberViewSet(BaseViewSet): ) def leave(self, request, slug): workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, + workspace__slug=slug, member=request.user, is_active=True ) # Check if the leaving user is the only admin of the workspace if ( workspace_member.role == 20 and not WorkspaceMember.objects.filter( - workspace__slug=slug, - role=20, - is_active=True, + workspace__slug=slug, role=20, is_active=True ).count() > 1 ): @@ -271,9 +227,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): class WorkspaceMemberUserViewsEndpoint(BaseAPIView): def post(self, request, slug): workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, + workspace__slug=slug, member=request.user, is_active=True ) workspace_member.view_props = request.data.get("view_props", {}) workspace_member.save() @@ -283,10 +237,25 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView): def get(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, + draft_issue_count = ( + DraftIssue.objects.filter( + created_by=request.user, workspace_id=OuterRef("workspace_id") + ) + .values("workspace_id") + .annotate(count=Count("id")) + .values("count") + ) + + workspace_member = ( + WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True + ) + .annotate( + draft_issue_count=Coalesce( + Subquery(draft_issue_count, output_field=IntegerField()), 0 + ) + ) + .first() ) serializer = WorkspaceMemberMeSerializer(workspace_member) return Response(serializer.data, status=status.HTTP_200_OK) @@ -296,26 +265,19 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): serializer_class = ProjectMemberRoleSerializer model = ProjectMember - permission_classes = [ - WorkspaceEntityPermission, - ] + permission_classes = [WorkspaceEntityPermission] def get(self, request, slug): # Fetch all project IDs where the user is involved project_ids = ( - ProjectMember.objects.filter( - member=request.user, - is_active=True, - ) + ProjectMember.objects.filter(member=request.user, is_active=True) .values_list("project_id", flat=True) .distinct() ) # Get all the project members in which the user is involved project_members = ProjectMember.objects.filter( - workspace__slug=slug, - project_id__in=project_ids, - is_active=True, + workspace__slug=slug, project_id__in=project_ids, is_active=True ).select_related("project", "member", "workspace") project_members = ProjectMemberRoleSerializer( project_members, many=True @@ -331,62 +293,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): project_members_dict[str(project_id)].append(project_member) return Response(project_members_dict, status=status.HTTP_200_OK) - - -class TeamMemberViewSet(BaseViewSet): - serializer_class = TeamSerializer - model = Team - permission_classes = [ - WorkSpaceAdminPermission, - ] - - search_fields = [ - "member__display_name", - "member__first_name", - ] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "workspace__owner") - .prefetch_related("members") - ) - - def create(self, request, slug): - members = list( - WorkspaceMember.objects.filter( - workspace__slug=slug, - member__id__in=request.data.get("members", []), - is_active=True, - ) - .annotate(member_str_id=Cast("member", output_field=CharField())) - .distinct() - .values_list("member_str_id", flat=True) - ) - - if len(members) != len(request.data.get("members", [])): - users = list( - set(request.data.get("members", [])).difference(members) - ) - users = User.objects.filter(pk__in=users) - - serializer = UserLiteSerializer(users, many=True) - return Response( - { - "error": f"{len(users)} of the member(s) are not a part of the workspace", - "members": serializer.data, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - serializer = TeamSerializer( - data=request.data, context={"workspace": workspace} - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index 7671692ec..e61fc70e7 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -1,9 +1,5 @@ # Django imports -from django.db.models import ( - Prefetch, - Q, - Count, -) +from django.db.models import Prefetch, Q, Count # Third party modules from rest_framework import status @@ -11,17 +7,13 @@ from rest_framework.response import Response # Module imports from plane.app.views.base import BaseAPIView -from plane.db.models import ( - Module, - ModuleLink, -) +from plane.db.models import Module, ModuleLink from plane.app.permissions import WorkspaceViewerPermission from plane.app.serializers.module import ModuleSerializer + class WorkspaceModulesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] + permission_classes = [WorkspaceViewerPermission] def get(self, request, slug): modules = ( @@ -34,9 +26,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related( - "module", "created_by" - ), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) .annotate( @@ -45,9 +35,10 @@ class WorkspaceModulesEndpoint(BaseAPIView): filter=Q( issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, - ), + ) ) .annotate( completed_issues=Count( @@ -56,6 +47,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__state__group="completed", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -67,6 +59,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__state__group="cancelled", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -78,6 +71,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__state__group="started", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -89,6 +83,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__state__group="unstarted", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) @@ -100,6 +95,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__state__group="backlog", issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, ), distinct=True, ) diff --git a/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py index c69b56d4f..c00044cff 100644 --- a/apiserver/plane/app/views/workspace/state.py +++ b/apiserver/plane/app/views/workspace/state.py @@ -9,10 +9,9 @@ from plane.db.models import State from plane.app.permissions import WorkspaceEntityPermission from plane.utils.cache import cache_response + class WorkspaceStatesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + permission_classes = [WorkspaceEntityPermission] @cache_response(60 * 60 * 2) def get(self, request, slug): diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 5c173f202..0010b4ae7 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -14,6 +14,7 @@ from django.db.models import ( Q, Value, When, + Subquery, ) from django.db.models.fields import DateField from django.db.models.functions import Cast, ExtractWeek @@ -23,10 +24,7 @@ from django.utils import timezone from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - WorkspaceEntityPermission, - WorkspaceViewerPermission, -) +from plane.app.permissions import WorkspaceEntityPermission, WorkspaceViewerPermission # Module imports from plane.app.serializers import ( @@ -40,7 +38,7 @@ from plane.db.models import ( CycleIssue, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, Project, @@ -57,10 +55,7 @@ from plane.utils.grouper import ( ) 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 plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): @@ -71,10 +66,7 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): if last_workspace_id is None: return Response( - { - "project_details": [], - "workspace_details": {}, - }, + {"project_details": [], "workspace_details": {}}, status=status.HTTP_200_OK, ) @@ -85,9 +77,7 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): workspace_id=last_workspace_id, member=request.user ).select_related("workspace", "project", "member", "workspace__owner") - project_member_serializer = ProjectMemberSerializer( - project_member, many=True - ) + project_member_serializer = ProjectMemberSerializer(project_member, many=True) return Response( { @@ -99,12 +89,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] + permission_classes = [WorkspaceViewerPermission] def get(self, request, slug, user_id): - filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") @@ -120,7 +107,13 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .filter(**filters) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -128,17 +121,16 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -148,8 +140,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -158,9 +149,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -178,28 +167,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): 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, + 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, + field=group_by, slug=slug, filters=filters ), sub_group_by_fields=issue_group_values( - field=sub_group_by, - slug=slug, - filters=filters, + 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -211,22 +194,18 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): 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, + 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, + 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -243,14 +222,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): class WorkspaceUserPropertiesEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceViewerPermission, - ] + permission_classes = [WorkspaceViewerPermission] def patch(self, request, slug): workspace_properties = WorkspaceUserProperties.objects.get( - user=request.user, - workspace__slug=slug, + user=request.user, workspace__slug=slug ) workspace_properties.filters = request.data.get( @@ -268,10 +244,7 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED) def get(self, request, slug): - ( - workspace_properties, - _, - ) = WorkspaceUserProperties.objects.get_or_create( + (workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create( user=request.user, workspace__slug=slug ) serializer = WorkspaceUserPropertiesSerializer(workspace_properties) @@ -283,9 +256,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): user_data = User.objects.get(pk=user_id) requesting_workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, - member=request.user, - is_active=True, + workspace__slug=slug, member=request.user, is_active=True ) projects = [] if requesting_workspace_member.role >= 15: @@ -359,8 +330,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): "email": user_data.email, "first_name": user_data.first_name, "last_name": user_data.last_name, - "avatar": user_data.avatar, - "cover_image": user_data.cover_image, + "avatar_url": user_data.avatar_url, + "cover_image_url": user_data.cover_image_url, "date_joined": user_data.date_joined, "user_timezone": user_data.user_timezone, "display_name": user_data.display_name, @@ -371,9 +342,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): class WorkspaceUserActivityEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + permission_classes = [WorkspaceEntityPermission] def get(self, request, slug, user_id): projects = request.query_params.getlist("project", []) @@ -504,19 +473,15 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): upcoming_cycles = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__gt=timezone.now().date(), - issue__assignees__in=[ - user_id, - ], + cycle__start_date__gt=timezone.now(), + issue__assignees__in=[user_id], ).values("cycle__name", "cycle__id", "cycle__project_id") present_cycle = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__lt=timezone.now().date(), - cycle__end_date__gt=timezone.now().date(), - issue__assignees__in=[ - user_id, - ], + cycle__start_date__lt=timezone.now(), + cycle__end_date__gt=timezone.now(), + issue__assignees__in=[user_id], ).values("cycle__name", "cycle__id", "cycle__project_id") return Response( diff --git a/apiserver/plane/asgi.py b/apiserver/plane/asgi.py index 7333baae3..2dd703ffe 100644 --- a/apiserver/plane/asgi.py +++ b/apiserver/plane/asgi.py @@ -11,8 +11,4 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") # is populated before importing code that may import ORM models. -application = ProtocolTypeRouter( - { - "http": get_asgi_application(), - } -) +application = ProtocolTypeRouter({"http": get_asgi_application()}) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 906d55700..c7a8c43d3 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -11,11 +11,7 @@ from django.core.exceptions import ValidationError from zxcvbn import zxcvbn # Module imports -from plane.db.models import ( - Profile, - User, - WorkspaceMemberInvite, -) +from plane.db.models import Profile, User, WorkspaceMemberInvite 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 @@ -90,20 +86,13 @@ class Adapter: # Get configuration value (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "1"), - }, - ] + [{"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() + and not WorkspaceMemberInvite.objects.filter(email=email).exists() ): # Raise exception raise AuthenticationException( @@ -124,9 +113,7 @@ class Adapter: 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 - ) + user_activation_email.delay(base_host(request=self.request), user.id) # Set user as active user.is_active = True user.save() @@ -182,11 +169,7 @@ class Adapter: # Call callback if present if self.callback: - self.callback( - user, - is_signup, - self.request, - ) + self.callback(user, is_signup, self.request) # Create or update account if token data is present if self.token_data: diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 90e236a80..63fafffbe 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -67,7 +67,6 @@ AUTHENTICATION_ERROR_CODES = { class AuthenticationException(Exception): - error_code = None error_message = None payload = {} @@ -78,10 +77,7 @@ class AuthenticationException(Exception): self.payload = payload def get_error_dict(self): - error = { - "error_code": self.error_code, - "error_message": self.error_message, - } + error = {"error_code": self.error_code, "error_message": self.error_message} for key in self.payload: error[key] = self.payload[key] diff --git a/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py index a6f7637a9..e906c5a50 100644 --- a/apiserver/plane/authentication/adapter/exception.py +++ b/apiserver/plane/authentication/adapter/exception.py @@ -4,7 +4,10 @@ from rest_framework.exceptions import NotAuthenticated from rest_framework.exceptions import Throttled # Module imports -from plane.authentication.adapter.error import AuthenticationException, AUTHENTICATION_ERROR_CODES +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) def auth_exception_handler(exc, context): @@ -22,6 +25,6 @@ def auth_exception_handler(exc, context): ) response.data = exc.get_error_dict() response.status_code = 429 - + # Return the response that is generated by the default exception handler. return response diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index 348f32f66..e89383837 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -3,6 +3,7 @@ import requests # Django imports from django.utils import timezone +from django.db import DatabaseError, IntegrityError # Module imports from plane.db.models import Account @@ -12,6 +13,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) +from plane.utils.exception_logger import log_exception class OauthAdapter(Adapter): @@ -66,51 +68,69 @@ class OauthAdapter(Adapter): def get_user_token(self, data, headers=None): try: headers = headers or {} - response = requests.post( - self.get_token_url(), data=data, headers=headers - ) + response = requests.post(self.get_token_url(), data=data, headers=headers) response.raise_for_status() return response.json() except requests.RequestException: code = self.authentication_error_code() raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[code], - error_message=str(code), + error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code) ) def get_user_response(self): try: - headers = { - "Authorization": f"Bearer {self.token_data.get('access_token')}" - } + headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} response = requests.get(self.get_user_info_url(), headers=headers) response.raise_for_status() return response.json() except requests.RequestException: code = self.authentication_error_code() raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[code], - error_message=str(code), + error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code) ) def set_user_data(self, data): self.user_data = data def create_update_account(self, user): - account, created = Account.objects.update_or_create( - user=user, - provider=self.provider, - provider_account_id=self.user_data.get("user").get("provider_id"), - defaults={ - "access_token": self.token_data.get("access_token"), - "refresh_token": self.token_data.get("refresh_token", None), - "access_token_expired_at": self.token_data.get( + try: + # Check if the account already exists + account = Account.objects.filter( + user=user, + provider=self.provider, + provider_account_id=self.user_data.get("user").get("provider_id"), + ).first() + # Update the account if it exists + if account: + account.access_token = self.token_data.get("access_token") + account.refresh_token = self.token_data.get("refresh_token", None) + account.access_token_expired_at = self.token_data.get( "access_token_expired_at" - ), - "refresh_token_expired_at": self.token_data.get( + ) + account.refresh_token_expired_at = self.token_data.get( "refresh_token_expired_at" - ), - "last_connected_at": timezone.now(), - "id_token": self.token_data.get("id_token", ""), - }, - ) + ) + account.last_connected_at = timezone.now() + account.id_token = self.token_data.get("id_token", "") + account.save() + # Create a new account if it does not exist + else: + Account.objects.create( + user=user, + provider=self.provider, + provider_account_id=self.user_data.get("user", {}).get( + "provider_id" + ), + access_token=self.token_data.get("access_token"), + refresh_token=self.token_data.get("refresh_token", None), + access_token_expired_at=self.token_data.get( + "access_token_expired_at" + ), + refresh_token_expired_at=self.token_data.get( + "refresh_token_expired_at" + ), + last_connected_at=timezone.now(), + id_token=self.token_data.get("id_token", ""), + ) + except (DatabaseError, IntegrityError) as e: + log_exception(e) diff --git a/apiserver/plane/authentication/middleware/session.py b/apiserver/plane/authentication/middleware/session.py index 2bb62b881..822c88316 100644 --- a/apiserver/plane/authentication/middleware/session.py +++ b/apiserver/plane/authentication/middleware/session.py @@ -17,9 +17,7 @@ class SessionMiddleware(MiddlewareMixin): def process_request(self, request): if "instances" in request.path: - session_key = request.COOKIES.get( - settings.ADMIN_SESSION_COOKIE_NAME - ) + session_key = request.COOKIES.get(settings.ADMIN_SESSION_COOKIE_NAME) else: session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) request.session = self.SessionStore(session_key) diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apiserver/plane/authentication/provider/credentials/email.py index 4c7764128..4b8ae0595 100644 --- a/apiserver/plane/authentication/provider/credentials/email.py +++ b/apiserver/plane/authentication/provider/credentials/email.py @@ -12,20 +12,10 @@ from plane.license.utils.instance_value import get_configuration_value class EmailProvider(CredentialAdapter): - provider = "email" - def __init__( - self, - request, - key=None, - code=None, - is_signup=False, - callback=None, - ): - super().__init__( - request=request, provider=self.provider, callback=callback - ) + def __init__(self, request, key=None, code=None, is_signup=False, callback=None): + super().__init__(request=request, provider=self.provider, callback=callback) self.key = key self.code = code self.is_signup = is_signup @@ -35,7 +25,7 @@ class EmailProvider(CredentialAdapter): { "key": "ENABLE_EMAIL_PASSWORD", "default": os.environ.get("ENABLE_EMAIL_PASSWORD"), - }, + } ] ) @@ -53,9 +43,7 @@ class EmailProvider(CredentialAdapter): if User.objects.filter(email=self.key).exists(): raise AuthenticationException( error_message="USER_ALREADY_EXIST", - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ALREADY_EXIST" - ], + error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], ) super().set_user_data( @@ -72,20 +60,14 @@ class EmailProvider(CredentialAdapter): ) return else: - user = User.objects.filter( - email=self.key, - ).first() + user = User.objects.filter(email=self.key).first() # User does not exists if not user: raise AuthenticationException( error_message="USER_DOES_NOT_EXIST", - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_DOES_NOT_EXIST" - ], - payload={ - "email": self.key, - }, + error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], + payload={"email": self.key}, ) # Check user password diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index 418dd2a06..4fe8924f3 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -17,26 +17,12 @@ from plane.db.models import User class MagicCodeProvider(CredentialAdapter): - provider = "magic-code" - def __init__( - self, - request, - key, - code=None, - callback=None, - ): - - ( - EMAIL_HOST, - ENABLE_MAGIC_LINK_LOGIN, - ) = get_configuration_value( + def __init__(self, request, key, code=None, callback=None): + (EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value( [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -53,16 +39,12 @@ class MagicCodeProvider(CredentialAdapter): if ENABLE_MAGIC_LINK_LOGIN == "0": raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "MAGIC_LINK_LOGIN_DISABLED" - ], + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_LINK_LOGIN_DISABLED"], error_message="MAGIC_LINK_LOGIN_DISABLED", payload={"email": str(key)}, ) - super().__init__( - request=request, provider=self.provider, callback=callback - ) + super().__init__(request=request, provider=self.provider, callback=callback) self.key = key self.code = code @@ -164,17 +146,13 @@ class MagicCodeProvider(CredentialAdapter): email = str(self.key).replace("magic_", "", 1) if User.objects.filter(email=email).exists(): raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EXPIRED_MAGIC_CODE_SIGN_IN" - ], + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_MAGIC_CODE_SIGN_IN"], error_message="EXPIRED_MAGIC_CODE_SIGN_IN", payload={"email": str(email)}, ) else: raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EXPIRED_MAGIC_CODE_SIGN_UP" - ], + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_MAGIC_CODE_SIGN_UP"], error_message="EXPIRED_MAGIC_CODE_SIGN_UP", payload={"email": str(email)}, ) diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index edd99b1ba..1808aa515 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -16,14 +16,12 @@ from plane.authentication.adapter.error import ( class GitHubOAuthProvider(OauthAdapter): - token_url = "https://github.com/login/oauth/access_token" userinfo_url = "https://api.github.com/user" provider = "github" scope = "read:user user:email" def __init__(self, request, code=None, state=None, callback=None): - GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( [ { @@ -53,9 +51,7 @@ class GitHubOAuthProvider(OauthAdapter): "scope": self.scope, "state": state, } - auth_url = ( - f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" - ) + auth_url = f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" super().__init__( request, self.provider, @@ -86,16 +82,14 @@ class GitHubOAuthProvider(OauthAdapter): "refresh_token": token_response.get("refresh_token", None), "access_token_expired_at": ( datetime.fromtimestamp( - token_response.get("expires_in"), - tz=pytz.utc, + 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, + token_response.get("refresh_token_expired_at"), tz=pytz.utc ) if token_response.get("refresh_token_expired_at") else None @@ -110,19 +104,12 @@ class GitHubOAuthProvider(OauthAdapter): emails_url = "https://api.github.com/user/emails" emails_response = requests.get(emails_url, headers=headers).json() email = next( - ( - email["email"] - for email in emails_response - if email["primary"] - ), - None, + (email["email"] for email in emails_response if email["primary"]), None ) return email except requests.RequestException: raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITHUB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) diff --git a/apiserver/plane/authentication/provider/oauth/gitlab.py b/apiserver/plane/authentication/provider/oauth/gitlab.py index 3795cc37f..df6fb7c44 100644 --- a/apiserver/plane/authentication/provider/oauth/gitlab.py +++ b/apiserver/plane/authentication/provider/oauth/gitlab.py @@ -15,31 +15,25 @@ from plane.authentication.adapter.error import ( 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" - ), - }, - ] - ) + 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 @@ -104,8 +98,7 @@ class GitLabOAuthProvider(OauthAdapter): ), "refresh_token_expired_at": ( datetime.fromtimestamp( - token_response.get("refresh_token_expired_at"), - tz=pytz.utc, + token_response.get("refresh_token_expired_at"), tz=pytz.utc ) if token_response.get("refresh_token_expired_at") else None diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apiserver/plane/authentication/provider/oauth/google.py index 9c17a75af..d3f683619 100644 --- a/apiserver/plane/authentication/provider/oauth/google.py +++ b/apiserver/plane/authentication/provider/oauth/google.py @@ -53,7 +53,9 @@ class GoogleOAuthProvider(OauthAdapter): "prompt": "consent", "state": state, } - auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" + auth_url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" + ) super().__init__( request, @@ -84,16 +86,14 @@ class GoogleOAuthProvider(OauthAdapter): "refresh_token": token_response.get("refresh_token", None), "access_token_expired_at": ( datetime.fromtimestamp( - token_response.get("expires_in"), - tz=pytz.utc, + 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, + token_response.get("refresh_token_expired_at"), tz=pytz.utc ) if token_response.get("refresh_token_expired_at") else None diff --git a/apiserver/plane/authentication/session.py b/apiserver/plane/authentication/session.py index 7bb0b4a00..862a63c13 100644 --- a/apiserver/plane/authentication/session.py +++ b/apiserver/plane/authentication/session.py @@ -2,7 +2,6 @@ from rest_framework.authentication import SessionAuthentication class BaseSessionAuthentication(SessionAuthentication): - # Disable csrf for the rest apis def enforce_csrf(self, request): return diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index a375d94cb..b52cf2a21 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -40,64 +40,20 @@ from .views import ( urlpatterns = [ # credentials - path( - "sign-in/", - SignInAuthEndpoint.as_view(), - name="sign-in", - ), - path( - "sign-up/", - SignUpAuthEndpoint.as_view(), - name="sign-up", - ), - path( - "spaces/sign-in/", - SignInAuthSpaceEndpoint.as_view(), - name="sign-in", - ), - path( - "spaces/sign-up/", - SignUpAuthSpaceEndpoint.as_view(), - name="sign-in", - ), + path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), + path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), + path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"), + path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"), # signout - path( - "sign-out/", - SignOutAuthEndpoint.as_view(), - name="sign-out", - ), - path( - "spaces/sign-out/", - SignOutAuthSpaceEndpoint.as_view(), - name="sign-out", - ), + path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), + path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"), # csrf token - path( - "get-csrf-token/", - CSRFTokenEndpoint.as_view(), - name="get_csrf_token", - ), + path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), # Magic sign in - path( - "magic-generate/", - MagicGenerateEndpoint.as_view(), - name="magic-generate", - ), - path( - "magic-sign-in/", - MagicSignInEndpoint.as_view(), - name="magic-sign-in", - ), - path( - "magic-sign-up/", - MagicSignUpEndpoint.as_view(), - name="magic-sign-up", - ), - path( - "get-csrf-token/", - CSRFTokenEndpoint.as_view(), - name="get_csrf_token", - ), + path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), + path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), + path("magic-sign-up/", MagicSignUpEndpoint.as_view(), name="magic-sign-up"), + path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), path( "spaces/magic-generate/", MagicGenerateSpaceEndpoint.as_view(), @@ -114,16 +70,8 @@ urlpatterns = [ name="magic-sign-up", ), ## Google Oauth - path( - "google/", - GoogleOauthInitiateEndpoint.as_view(), - name="google-initiate", - ), - path( - "google/callback/", - GoogleCallbackEndpoint.as_view(), - name="google-callback", - ), + path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), + path("google/callback/", GoogleCallbackEndpoint.as_view(), name="google-callback"), path( "spaces/google/", GoogleOauthInitiateSpaceEndpoint.as_view(), @@ -135,16 +83,8 @@ urlpatterns = [ name="google-callback", ), ## Github Oauth - path( - "github/", - GitHubOauthInitiateEndpoint.as_view(), - name="github-initiate", - ), - path( - "github/callback/", - GitHubCallbackEndpoint.as_view(), - name="github-callback", - ), + path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), + path("github/callback/", GitHubCallbackEndpoint.as_view(), name="github-callback"), path( "spaces/github/", GitHubOauthInitiateSpaceEndpoint.as_view(), @@ -156,16 +96,8 @@ urlpatterns = [ name="github-callback", ), ## Gitlab Oauth - path( - "gitlab/", - GitLabOauthInitiateEndpoint.as_view(), - name="gitlab-initiate", - ), - path( - "gitlab/callback/", - GitLabCallbackEndpoint.as_view(), - name="gitlab-callback", - ), + path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), + path("gitlab/callback/", GitLabCallbackEndpoint.as_view(), name="gitlab-callback"), path( "spaces/gitlab/", GitLabOauthInitiateSpaceEndpoint.as_view(), @@ -177,22 +109,10 @@ urlpatterns = [ name="gitlab-callback", ), # Email Check - path( - "email-check/", - EmailCheckEndpoint.as_view(), - name="email-check", - ), - path( - "spaces/email-check/", - EmailCheckSpaceEndpoint.as_view(), - name="email-check", - ), + path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"), + path("spaces/email-check/", EmailCheckSpaceEndpoint.as_view(), name="email-check"), # Password - path( - "forgot-password/", - ForgotPasswordEndpoint.as_view(), - name="forgot-password", - ), + path("forgot-password/", ForgotPasswordEndpoint.as_view(), name="forgot-password"), path( "reset-password///", ResetPasswordEndpoint.as_view(), @@ -208,14 +128,6 @@ urlpatterns = [ ResetPasswordSpaceEndpoint.as_view(), name="forgot-password", ), - path( - "change-password/", - ChangePasswordEndpoint.as_view(), - name="forgot-password", - ), - path( - "set-password/", - SetUserPasswordEndpoint.as_view(), - name="set-password", - ), + path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), + path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), ] diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py index f5d453d02..ba7f9d1e1 100644 --- a/apiserver/plane/authentication/utils/login.py +++ b/apiserver/plane/authentication/utils/login.py @@ -17,10 +17,7 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False): "user_agent": request.META.get("HTTP_USER_AGENT", ""), "ip_address": request.META.get("REMOTE_ADDR", ""), "domain": base_host( - request=request, - is_app=is_app, - is_admin=is_admin, - is_space=is_space, + request=request, is_app=is_app, is_admin=is_admin, is_space=is_space ), } request.session["device_info"] = device_info diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apiserver/plane/authentication/utils/redirection_path.py index 62cc1f019..459ad7434 100644 --- a/apiserver/plane/authentication/utils/redirection_path.py +++ b/apiserver/plane/authentication/utils/redirection_path.py @@ -27,8 +27,7 @@ def get_redirection_path(user): fallback_workspace = ( Workspace.objects.filter( - workspace_member__member_id=user.id, - workspace_member__is_active=True, + workspace_member__member_id=user.id, workspace_member__is_active=True ) .order_by("created_at") .first() diff --git a/apiserver/plane/authentication/utils/user_auth_workflow.py b/apiserver/plane/authentication/utils/user_auth_workflow.py index e7cb4942e..13de4c287 100644 --- a/apiserver/plane/authentication/utils/user_auth_workflow.py +++ b/apiserver/plane/authentication/utils/user_auth_workflow.py @@ -1,9 +1,5 @@ from .workspace_project_join import process_workspace_project_invitations -def post_user_auth_workflow( - user, - is_signup, - request, -): +def post_user_auth_workflow(user, is_signup, request): process_workspace_project_invitations(user=user) diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index af58a9cbd..24ae1f673 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -1,52 +1,23 @@ -from .common import ( - ChangePasswordEndpoint, - CSRFTokenEndpoint, - SetUserPasswordEndpoint, -) +from .common import ChangePasswordEndpoint, CSRFTokenEndpoint, SetUserPasswordEndpoint from .app.check import EmailCheckEndpoint -from .app.email import ( - SignInAuthEndpoint, - SignUpAuthEndpoint, -) -from .app.github import ( - GitHubCallbackEndpoint, - GitHubOauthInitiateEndpoint, -) -from .app.gitlab import ( - GitLabCallbackEndpoint, - GitLabOauthInitiateEndpoint, -) -from .app.google import ( - GoogleCallbackEndpoint, - GoogleOauthInitiateEndpoint, -) -from .app.magic import ( - MagicGenerateEndpoint, - MagicSignInEndpoint, - MagicSignUpEndpoint, -) +from .app.email import SignInAuthEndpoint, SignUpAuthEndpoint +from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint +from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint +from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint +from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint from .app.signout import SignOutAuthEndpoint from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint -from .space.github import ( - GitHubCallbackSpaceEndpoint, - GitHubOauthInitiateSpaceEndpoint, -) +from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceEndpoint -from .space.gitlab import ( - GitLabCallbackSpaceEndpoint, - GitLabOauthInitiateSpaceEndpoint, -) +from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint -from .space.google import ( - GoogleCallbackSpaceEndpoint, - GoogleOauthInitiateSpaceEndpoint, -) +from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint from .space.magic import ( MagicGenerateSpaceEndpoint, @@ -62,7 +33,4 @@ from .space.password_management import ( ForgotPasswordSpaceEndpoint, ResetPasswordSpaceEndpoint, ) -from .app.password_management import ( - ForgotPasswordEndpoint, - ResetPasswordEndpoint, -) +from .app.password_management import ForgotPasswordEndpoint, ResetPasswordEndpoint diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 6af8859fc..c7e4b8a5e 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -19,42 +19,27 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, ) from plane.authentication.rate_limit import AuthenticationThrottle -from plane.license.utils.instance_value import ( - get_configuration_value, -) +from plane.license.utils.instance_value import get_configuration_value class EmailCheckEndpoint(APIView): + permission_classes = [AllowAny] - permission_classes = [ - AllowAny, - ] - - throttle_classes = [ - AuthenticationThrottle, - ] + throttle_classes = [AuthenticationThrottle] def post(self, request): # 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) (EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value( [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST", ""), - }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -73,10 +58,7 @@ class EmailCheckEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], error_message="EMAIL_REQUIRED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Validate email try: @@ -86,10 +68,7 @@ class EmailCheckEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Check if a user already exists with the given email existing_user = User.objects.filter(email=email).first() diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 08a3e8b01..805b273a1 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -13,9 +13,7 @@ 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.utils.redirection_path import get_redirection_path -from plane.authentication.utils.user_auth_workflow import ( - post_user_auth_workflow, -) +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow from plane.db.models import User from plane.authentication.adapter.error import ( AuthenticationException, @@ -24,7 +22,6 @@ from plane.authentication.adapter.error import ( class SignInAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -32,9 +29,7 @@ class SignInAuthEndpoint(View): if instance is None or not instance.is_setup_done: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() @@ -42,8 +37,7 @@ class SignInAuthEndpoint(View): params["next_path"] = str(next_path) # Base URL join url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -66,8 +60,7 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -85,8 +78,7 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -102,8 +94,7 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -132,14 +123,12 @@ class SignInAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) class SignUpAuthEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -147,17 +136,14 @@ class SignUpAuthEndpoint(View): if instance is None or not instance.is_setup_done: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + 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), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -177,8 +163,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) # Validate the email @@ -196,8 +181,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -215,8 +199,7 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -244,7 +227,6 @@ class SignUpAuthEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py index f93beefa3..f1a15474c 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -9,9 +9,7 @@ from django.views import View from plane.authentication.provider.oauth.github import GitHubOAuthProvider 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.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 ( @@ -21,7 +19,6 @@ from plane.authentication.adapter.error import ( class GitHubOauthInitiateEndpoint(View): - def get(self, request): # Get host and next path request.session["host"] = base_host(request=request, is_app=True) @@ -33,17 +30,14 @@ class GitHubOauthInitiateEndpoint(View): 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_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), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) try: @@ -57,14 +51,12 @@ class GitHubOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) class GitHubCallbackEndpoint(View): - def get(self, request): code = request.GET.get("code") state = request.GET.get("state") @@ -73,41 +65,29 @@ class GitHubCallbackEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITHUB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITHUB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) try: provider = GitHubOAuthProvider( - request=request, - code=code, - callback=post_user_auth_workflow, + request=request, code=code, callback=post_user_auth_workflow ) user = provider.authenticate() # Login the user and record his device info @@ -124,8 +104,5 @@ class GitHubCallbackEndpoint(View): params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apiserver/plane/authentication/views/app/gitlab.py index 02a44aeb4..bc0c9c8d7 100644 --- a/apiserver/plane/authentication/views/app/gitlab.py +++ b/apiserver/plane/authentication/views/app/gitlab.py @@ -9,9 +9,7 @@ from django.views import View 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.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 ( @@ -21,7 +19,6 @@ from plane.authentication.adapter.error import ( class GitLabOauthInitiateEndpoint(View): - def get(self, request): # Get host and next path request.session["host"] = base_host(request=request, is_app=True) @@ -33,17 +30,14 @@ class GitLabOauthInitiateEndpoint(View): 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_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), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) try: @@ -57,14 +51,12 @@ class GitLabOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + 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") @@ -73,41 +65,29 @@ class GitLabCallbackEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITLAB_OAUTH_PROVIDER_ERROR" - ], + 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), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITLAB_OAUTH_PROVIDER_ERROR" - ], + 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), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) try: provider = GitLabOAuthProvider( - request=request, - code=code, - callback=post_user_auth_workflow, + request=request, code=code, callback=post_user_auth_workflow ) user = provider.authenticate() # Login the user and record his device info @@ -124,8 +104,5 @@ class GitLabCallbackEndpoint(View): params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index 05f4511e2..46c0d1980 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -11,9 +11,7 @@ from django.views import View from plane.authentication.provider.oauth.google import GoogleOAuthProvider 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.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 ( @@ -33,17 +31,14 @@ class GoogleOauthInitiateEndpoint(View): 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_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), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -58,8 +53,7 @@ class GoogleOauthInitiateEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -73,39 +67,27 @@ class GoogleCallbackEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GOOGLE_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GOOGLE_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() if next_path: params["next_path"] = next_path - url = urljoin( - base_host, - "?" + urlencode(params), - ) + url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider( - request=request, - code=code, - callback=post_user_auth_workflow, + request=request, code=code, callback=post_user_auth_workflow ) user = provider.authenticate() # Login the user and record his device info @@ -119,8 +101,5 @@ class GoogleCallbackEndpoint(View): params = e.get_error_dict() if next_path: params["next_path"] = str(next_path) - url = urljoin( - base_host, - "?" + urlencode(params), - ) + 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 980eb4e7c..b3bf8c777 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -13,14 +13,10 @@ from rest_framework.response import Response from rest_framework.views import APIView # Module imports -from plane.authentication.provider.credentials.magic_code import ( - MagicCodeProvider, -) +from plane.authentication.provider.credentials.magic_code import MagicCodeProvider 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.authentication.utils.user_auth_workflow import post_user_auth_workflow from plane.bgtasks.magic_link_code_task import magic_link from plane.license.models import Instance from plane.authentication.utils.host import base_host @@ -33,34 +29,23 @@ from plane.authentication.rate_limit import AuthenticationThrottle class MagicGenerateEndpoint(APIView): + permission_classes = [AllowAny] - permission_classes = [ - AllowAny, - ] - - throttle_classes = [ - AuthenticationThrottle, - ] + throttle_classes = [AuthenticationThrottle] def post(self, request): # Check if instance is configured 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) origin = request.META.get("HTTP_ORIGIN", "/") - email = request.data.get("email", False) + email = request.data.get("email", "").strip().lower() try: - # Clean up the email - email = email.strip().lower() validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() @@ -69,16 +54,11 @@ class MagicGenerateEndpoint(APIView): return Response({"key": str(key)}, status=status.HTTP_200_OK) except AuthenticationException as e: params = e.get_error_dict() - return Response( - params, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(params, status=status.HTTP_400_BAD_REQUEST) class MagicSignInEndpoint(View): - def post(self, request): - # set the referer as session to redirect after login code = request.POST.get("code", "").strip() email = request.POST.get("email", "").strip().lower() @@ -95,8 +75,7 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -112,8 +91,7 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -146,16 +124,13 @@ class MagicSignInEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), + base_host(request=request, is_app=True), "sign-in?" + urlencode(params) ) return HttpResponseRedirect(url) class MagicSignUpEndpoint(View): - def post(self, request): - # set the referer as session to redirect after login code = request.POST.get("code", "").strip() email = request.POST.get("email", "").strip().lower() @@ -172,8 +147,7 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) # Existing user @@ -187,8 +161,7 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) @@ -216,7 +189,6 @@ class MagicSignUpEndpoint(View): if next_path: params["next_path"] = str(next_path) url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), + base_host(request=request, is_app=True), "?" + urlencode(params) ) return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index 43054867e..cad498e63 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -14,11 +14,7 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect -from django.utils.encoding import ( - DjangoUnicodeDecodeError, - smart_bytes, - smart_str, -) +from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views import View @@ -34,6 +30,7 @@ from plane.authentication.adapter.error import ( ) from plane.authentication.rate_limit import AuthenticationThrottle + def generate_password_token(user): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) token = PasswordResetTokenGenerator().make_token(user) @@ -42,13 +39,9 @@ def generate_password_token(user): class ForgotPasswordEndpoint(APIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] - throttle_classes = [ - AuthenticationThrottle, - ] + throttle_classes = [AuthenticationThrottle] def post(self, request): email = request.data.get("email") @@ -57,23 +50,13 @@ class ForgotPasswordEndpoint(APIView): 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) (EMAIL_HOST,) = get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - ] + [{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}] ) if not (EMAIL_HOST): @@ -81,10 +64,7 @@ class ForgotPasswordEndpoint(APIView): error_message="SMTP_NOT_CONFIGURED", error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) try: validate_email(email) @@ -93,10 +73,7 @@ class ForgotPasswordEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Get the user user = User.objects.filter(email=email).first() @@ -116,14 +93,10 @@ class ForgotPasswordEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) class ResetPasswordEndpoint(View): - def post(self, request, uidb64, token): try: # Decode the id from the uidb64 @@ -133,9 +106,7 @@ class ResetPasswordEndpoint(View): # check if the token is valid for the user if not PasswordResetTokenGenerator().check_token(user, token): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD_TOKEN" - ], + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], error_message="INVALID_PASSWORD_TOKEN", ) params = exc.get_error_dict() @@ -154,8 +125,7 @@ class ResetPasswordEndpoint(View): ) url = urljoin( base_host(request=request, is_app=True), - "accounts/reset-password?" - + urlencode(exc.get_error_dict()), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -168,8 +138,7 @@ class ResetPasswordEndpoint(View): ) url = urljoin( base_host(request=request, is_app=True), - "accounts/reset-password?" - + urlencode(exc.get_error_dict()), + "accounts/reset-password?" + urlencode(exc.get_error_dict()), ) return HttpResponseRedirect(url) @@ -185,9 +154,7 @@ class ResetPasswordEndpoint(View): return HttpResponseRedirect(url) except DjangoUnicodeDecodeError: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EXPIRED_PASSWORD_TOKEN" - ], + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], error_message="EXPIRED_PASSWORD_TOKEN", ) url = urljoin( diff --git a/apiserver/plane/authentication/views/app/signout.py b/apiserver/plane/authentication/views/app/signout.py index 260a89a8d..b8019dac1 100644 --- a/apiserver/plane/authentication/views/app/signout.py +++ b/apiserver/plane/authentication/views/app/signout.py @@ -10,7 +10,6 @@ from plane.db.models import User class SignOutAuthEndpoint(View): - def post(self, request): # Get user try: @@ -20,10 +19,6 @@ class SignOutAuthEndpoint(View): user.save() # Log the user out logout(request) - return HttpResponseRedirect( - base_host(request=request, is_app=True) - ) + return HttpResponseRedirect(base_host(request=request, is_app=True)) except Exception: - return HttpResponseRedirect( - base_host(request=request, is_app=True) - ) + return HttpResponseRedirect(base_host(request=request, is_app=True)) diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 3e95d6ed8..cdcf6bc96 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -9,9 +9,7 @@ from rest_framework.views import APIView from zxcvbn import zxcvbn ## Module imports -from plane.app.serializers import ( - UserSerializer, -) +from plane.app.serializers import UserSerializer from plane.authentication.utils.login import user_login from plane.db.models import User from plane.authentication.adapter.error import ( @@ -22,24 +20,24 @@ from django.middleware.csrf import get_token from plane.utils.cache import invalidate_cache from plane.authentication.utils.host import base_host -class CSRFTokenEndpoint(APIView): - permission_classes = [ - AllowAny, - ] +class CSRFTokenEndpoint(APIView): + permission_classes = [AllowAny] def get(self, request): # Generate a CSRF token csrf_token = get_token(request) # Return the CSRF token in a JSON response - return Response( - {"csrf_token": str(csrf_token)}, status=status.HTTP_200_OK - ) + return Response({"csrf_token": str(csrf_token)}, status=status.HTTP_200_OK) def csrf_failure(request, reason=""): """Custom CSRF failure view""" - return render(request, "csrf_failure.html", {"reason": reason, "root_url": base_host(request=request)}) + return render( + request, + "csrf_failure.html", + {"reason": reason, "root_url": base_host(request=request)}, + ) class ChangePasswordEndpoint(APIView): @@ -55,23 +53,15 @@ class ChangePasswordEndpoint(APIView): error_message="MISSING_PASSWORD", payload={"error": "Old or new password is missing"}, ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) if not user.check_password(old_password): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INCORRECT_OLD_PASSWORD" - ], + error_code=AUTHENTICATION_ERROR_CODES["INCORRECT_OLD_PASSWORD"], error_message="INCORRECT_OLD_PASSWORD", payload={"error": "Old password is not correct"}, ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # check the password score results = zxcvbn(new_password) @@ -80,10 +70,7 @@ class ChangePasswordEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"], error_message="INVALID_NEW_PASSWORD", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # set_password also hashes the password that the user will get user.set_password(new_password) @@ -91,13 +78,11 @@ class ChangePasswordEndpoint(APIView): user.save() user_login(user=user, request=request, is_app=True) return Response( - {"message": "Password updated successfully"}, - status=status.HTTP_200_OK, + {"message": "Password updated successfully"}, status=status.HTTP_200_OK ) class SetUserPasswordEndpoint(APIView): - @invalidate_cache("/api/users/me/") def post(self, request): user = User.objects.get(pk=request.user.id) @@ -112,10 +97,7 @@ class SetUserPasswordEndpoint(APIView): "error": "Your password is already set please change your password from profile" }, ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Check password validation if not password: @@ -123,10 +105,7 @@ class SetUserPasswordEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], error_message="INVALID_PASSWORD", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) results = zxcvbn(password) if results["score"] < 3: @@ -134,10 +113,7 @@ class SetUserPasswordEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], error_message="INVALID_PASSWORD", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Set the user password user.set_password(password) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 560ae0e31..9b4d8aa56 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -23,36 +23,23 @@ from plane.license.utils.instance_value import get_configuration_value class EmailCheckSpaceEndpoint(APIView): + permission_classes = [AllowAny] - permission_classes = [ - AllowAny, - ] - - throttle_classes = [ - AuthenticationThrottle, - ] + throttle_classes = [AuthenticationThrottle] def post(self, request): # 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) (EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value( [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST", ""), - }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -71,10 +58,7 @@ class EmailCheckSpaceEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["EMAIL_REQUIRED"], error_message="EMAIL_REQUIRED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Validate email try: @@ -84,10 +68,7 @@ class EmailCheckSpaceEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Check if a user already exists with the given email existing_user = User.objects.filter(email=email).first() diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 4329ed26d..278cdf80b 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -20,7 +20,6 @@ from plane.authentication.adapter.error import ( class SignInAuthSpaceEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -28,9 +27,7 @@ class SignInAuthSpaceEndpoint(View): if instance is None or not instance.is_setup_done: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() @@ -108,7 +105,6 @@ class SignInAuthSpaceEndpoint(View): class SignUpAuthSpaceEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") # Check instance configuration @@ -116,9 +112,7 @@ class SignUpAuthSpaceEndpoint(View): if instance is None or not instance.is_setup_done: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() diff --git a/apiserver/plane/authentication/views/space/github.py b/apiserver/plane/authentication/views/space/github.py index 711f7eaa7..1d9d1d4ee 100644 --- a/apiserver/plane/authentication/views/space/github.py +++ b/apiserver/plane/authentication/views/space/github.py @@ -18,7 +18,6 @@ from plane.authentication.adapter.error import ( class GitHubOauthInitiateSpaceEndpoint(View): - def get(self, request): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) @@ -30,9 +29,7 @@ class GitHubOauthInitiateSpaceEndpoint(View): 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() @@ -56,7 +53,6 @@ class GitHubOauthInitiateSpaceEndpoint(View): class GitHubCallbackSpaceEndpoint(View): - def get(self, request): code = request.GET.get("code") state = request.GET.get("state") @@ -65,9 +61,7 @@ class GitHubCallbackSpaceEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITHUB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -78,9 +72,7 @@ class GitHubCallbackSpaceEndpoint(View): if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITHUB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -90,10 +82,7 @@ class GitHubCallbackSpaceEndpoint(View): return HttpResponseRedirect(url) try: - provider = GitHubOAuthProvider( - request=request, - code=code, - ) + provider = GitHubOAuthProvider(request=request, code=code) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) diff --git a/apiserver/plane/authentication/views/space/gitlab.py b/apiserver/plane/authentication/views/space/gitlab.py index 7ebd9d187..9fb314442 100644 --- a/apiserver/plane/authentication/views/space/gitlab.py +++ b/apiserver/plane/authentication/views/space/gitlab.py @@ -18,7 +18,6 @@ from plane.authentication.adapter.error import ( class GitLabOauthInitiateSpaceEndpoint(View): - def get(self, request): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) @@ -30,9 +29,7 @@ class GitLabOauthInitiateSpaceEndpoint(View): 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() @@ -56,7 +53,6 @@ class GitLabOauthInitiateSpaceEndpoint(View): class GitLabCallbackSpaceEndpoint(View): - def get(self, request): code = request.GET.get("code") state = request.GET.get("state") @@ -65,9 +61,7 @@ class GitLabCallbackSpaceEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITLAB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -78,9 +72,7 @@ class GitLabCallbackSpaceEndpoint(View): if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GITLAB_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_OAUTH_PROVIDER_ERROR"], error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -90,10 +82,7 @@ class GitLabCallbackSpaceEndpoint(View): return HttpResponseRedirect(url) try: - provider = GitLabOAuthProvider( - request=request, - code=code, - ) + 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) diff --git a/apiserver/plane/authentication/views/space/google.py b/apiserver/plane/authentication/views/space/google.py index 38a2b910a..479a18883 100644 --- a/apiserver/plane/authentication/views/space/google.py +++ b/apiserver/plane/authentication/views/space/google.py @@ -28,9 +28,7 @@ class GoogleOauthInitiateSpaceEndpoint(View): 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() @@ -62,9 +60,7 @@ class GoogleCallbackSpaceEndpoint(View): if state != request.session.get("state", ""): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GOOGLE_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -74,9 +70,7 @@ class GoogleCallbackSpaceEndpoint(View): return HttpResponseRedirect(url) if not code: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "GOOGLE_OAUTH_PROVIDER_ERROR" - ], + error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_OAUTH_PROVIDER_ERROR"], error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() @@ -85,10 +79,7 @@ class GoogleCallbackSpaceEndpoint(View): url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) try: - provider = GoogleOAuthProvider( - request=request, - code=code, - ) + provider = GoogleOAuthProvider(request=request, code=code) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 838039f96..7c23d5fc3 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -13,9 +13,7 @@ from rest_framework.response import Response from rest_framework.views import APIView # Module imports -from plane.authentication.provider.credentials.magic_code import ( - MagicCodeProvider, -) +from plane.authentication.provider.credentials.magic_code import MagicCodeProvider from plane.authentication.utils.login import user_login from plane.bgtasks.magic_link_code_task import magic_link from plane.license.models import Instance @@ -28,30 +26,21 @@ from plane.authentication.adapter.error import ( class MagicGenerateSpaceEndpoint(APIView): - - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def post(self, request): # Check if instance is configured 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) origin = base_host(request=request, is_space=True) - email = request.data.get("email", False) + email = request.data.get("email", "").strip().lower() try: - # Clean up the email - email = email.strip().lower() validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() @@ -59,16 +48,11 @@ class MagicGenerateSpaceEndpoint(APIView): magic_link.delay(email, key, token, origin) return Response({"key": str(key)}, status=status.HTTP_200_OK) except AuthenticationException as e: - return Response( - e.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(e.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) class MagicSignInSpaceEndpoint(View): - def post(self, request): - # set the referer as session to redirect after login code = request.POST.get("code", "").strip() email = request.POST.get("email", "").strip().lower() @@ -122,9 +106,7 @@ class MagicSignInSpaceEndpoint(View): class MagicSignUpSpaceEndpoint(View): - def post(self, request): - # set the referer as session to redirect after login code = request.POST.get("code", "").strip() email = request.POST.get("email", "").strip().lower() diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py index 3e0379b96..710d0db2f 100644 --- a/apiserver/plane/authentication/views/space/password_management.py +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -14,11 +14,7 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect -from django.utils.encoding import ( - DjangoUnicodeDecodeError, - smart_bytes, - smart_str, -) +from django.utils.encoding import DjangoUnicodeDecodeError, smart_bytes, smart_str from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.views import View @@ -43,13 +39,9 @@ def generate_password_token(user): class ForgotPasswordSpaceEndpoint(APIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] - throttle_classes = [ - AuthenticationThrottle, - ] + throttle_classes = [AuthenticationThrottle] def post(self, request): email = request.data.get("email") @@ -58,33 +50,23 @@ class ForgotPasswordSpaceEndpoint(APIView): 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_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - ] - ) + (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD) = get_configuration_value( + [ + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + ] ) if not (EMAIL_HOST): @@ -92,10 +74,7 @@ class ForgotPasswordSpaceEndpoint(APIView): error_message="SMTP_NOT_CONFIGURED", error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) try: validate_email(email) @@ -104,10 +83,7 @@ class ForgotPasswordSpaceEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], error_message="INVALID_EMAIL", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Get the user user = User.objects.filter(email=email).first() @@ -127,14 +103,10 @@ class ForgotPasswordSpaceEndpoint(APIView): error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"], error_message="USER_DOES_NOT_EXIST", ) - return Response( - exc.get_error_dict(), - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) class ResetPasswordSpaceEndpoint(View): - def post(self, request, uidb64, token): try: # Decode the id from the uidb64 @@ -144,9 +116,7 @@ class ResetPasswordSpaceEndpoint(View): # check if the token is valid for the user if not PasswordResetTokenGenerator().check_token(user, token): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD_TOKEN" - ], + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], error_message="INVALID_PASSWORD_TOKEN", ) params = exc.get_error_dict() @@ -178,14 +148,10 @@ class ResetPasswordSpaceEndpoint(View): user.is_password_autoset = False user.save() - return HttpResponseRedirect( - base_host(request=request, is_space=True) - ) + return HttpResponseRedirect(base_host(request=request, is_space=True)) except DjangoUnicodeDecodeError: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EXPIRED_PASSWORD_TOKEN" - ], + error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], error_message="EXPIRED_PASSWORD_TOKEN", ) url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" diff --git a/apiserver/plane/authentication/views/space/signout.py b/apiserver/plane/authentication/views/space/signout.py index d3f29bd8d..babd18ee9 100644 --- a/apiserver/plane/authentication/views/space/signout.py +++ b/apiserver/plane/authentication/views/space/signout.py @@ -10,7 +10,6 @@ from plane.db.models import User class SignOutAuthSpaceEndpoint(View): - def post(self, request): next_path = request.POST.get("next_path") diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index e6788df79..0a335e5b5 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -10,6 +10,9 @@ from celery import shared_task from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.db.models import Q, Case, Value, When +from django.db import models +from django.db.models.functions import Concat # Module imports from plane.db.models import Issue @@ -84,12 +87,34 @@ def get_assignee_details(slug, filters): """Fetch assignee details if required.""" return ( Issue.issue_objects.filter( - workspace__slug=slug, **filters, assignees__avatar__isnull=False + Q( + Q(assignees__avatar__isnull=False) + | Q(assignees__avatar_asset__isnull=False) + ), + workspace__slug=slug, + **filters, + ) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), + default=Value(None), + output_field=models.CharField(), + ) ) .distinct("assignees__id") .order_by("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", @@ -102,7 +127,10 @@ def get_label_details(slug, filters): """Fetch label details if required""" return ( Issue.objects.filter( - workspace__slug=slug, **filters, labels__id__isnull=False + workspace__slug=slug, + **filters, + labels__id__isnull=False, + label_issue__deleted_at__isnull=True, ) .distinct("labels__id") .order_by("labels__id") @@ -112,10 +140,7 @@ def get_label_details(slug, filters): def get_state_details(slug, filters): return ( - Issue.issue_objects.filter( - workspace__slug=slug, - **filters, - ) + Issue.issue_objects.filter(workspace__slug=slug, **filters) .distinct("state_id") .order_by("state_id") .values("state_id", "state__name", "state__color") @@ -128,13 +153,11 @@ def get_module_details(slug, filters): workspace__slug=slug, **filters, issue_module__module_id__isnull=False, + issue_module__deleted_at__isnull=True, ) .distinct("issue_module__module_id") .order_by("issue_module__module_id") - .values( - "issue_module__module_id", - "issue_module__module__name", - ) + .values("issue_module__module_id", "issue_module__module__name") ) @@ -144,13 +167,11 @@ def get_cycle_details(slug, filters): workspace__slug=slug, **filters, issue_cycle__cycle_id__isnull=False, + issue_cycle__deleted_at__isnull=True, ) .distinct("issue_cycle__cycle_id") .order_by("issue_cycle__cycle_id") - .values( - "issue_cycle__cycle_id", - "issue_cycle__cycle__name", - ) + .values("issue_cycle__cycle_id", "issue_cycle__cycle__name") ) @@ -176,9 +197,7 @@ def generate_segmented_rows( ): segment_zero = list( set( - item.get("segment") - for sublist in distribution.values() - for item in sublist + item.get("segment") for sublist in distribution.values() for item in sublist ) ) @@ -197,9 +216,7 @@ def generate_segmented_rows( ] for segment in segment_zero: - value = next( - (x.get(key) for x in data if x.get("segment") == segment), "0" - ) + value = next((x.get(key) for x in data if x.get("segment") == segment), "0") generated_row.append(value) if x_axis == ASSIGNEE_ID: @@ -218,12 +235,7 @@ def generate_segmented_rows( if x_axis == LABEL_ID: label = next( - ( - lab - for lab in label_details - if str(lab[LABEL_ID]) == str(item) - ), - None, + (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None ) if label: @@ -231,12 +243,7 @@ def generate_segmented_rows( if x_axis == STATE_ID: state = next( - ( - sta - for sta in state_details - if str(sta[STATE_ID]) == str(item) - ), - None, + (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None ) if state: @@ -244,12 +251,7 @@ def generate_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - ( - cyc - for cyc in cycle_details - if str(cyc[CYCLE_ID]) == str(item) - ), - None, + (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None ) if cycle: @@ -257,11 +259,7 @@ def generate_segmented_rows( if x_axis == MODULE_ID: module = next( - ( - mod - for mod in module_details - if str(mod[MODULE_ID]) == str(item) - ), + (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), None, ) @@ -288,12 +286,7 @@ def generate_segmented_rows( if segmented == LABEL_ID: for index, segm in enumerate(row_zero[2:]): label = next( - ( - lab - for lab in label_details - if str(lab[LABEL_ID]) == str(segm) - ), - None, + (lab for lab in label_details if str(lab[LABEL_ID]) == str(segm)), None ) if label: row_zero[index + 2] = label["labels__name"] @@ -301,12 +294,7 @@ def generate_segmented_rows( if segmented == STATE_ID: for index, segm in enumerate(row_zero[2:]): state = next( - ( - sta - for sta in state_details - if str(sta[STATE_ID]) == str(segm) - ), - None, + (sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), None ) if state: row_zero[index + 2] = state["state__name"] @@ -314,12 +302,7 @@ def generate_segmented_rows( if segmented == MODULE_ID: for index, segm in enumerate(row_zero[2:]): module = next( - ( - mod - for mod in label_details - if str(mod[MODULE_ID]) == str(segm) - ), - None, + (mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), None ) if module: row_zero[index + 2] = module["issue_module__module__name"] @@ -327,12 +310,7 @@ def generate_segmented_rows( if segmented == CYCLE_ID: for index, segm in enumerate(row_zero[2:]): cycle = next( - ( - cyc - for cyc in cycle_details - if str(cyc[CYCLE_ID]) == str(segm) - ), - None, + (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), None ) if cycle: row_zero[index + 2] = cycle["issue_cycle__cycle__name"] @@ -353,10 +331,7 @@ def generate_non_segmented_rows( ): rows = [] for item, data in distribution.items(): - row = [ - item, - data[0].get("count" if y_axis == "issue_count" else "estimate"), - ] + row = [item, data[0].get("count" if y_axis == "issue_count" else "estimate")] if x_axis == ASSIGNEE_ID: assignee = next( @@ -374,12 +349,7 @@ def generate_non_segmented_rows( if x_axis == LABEL_ID: label = next( - ( - lab - for lab in label_details - if str(lab[LABEL_ID]) == str(item) - ), - None, + (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None ) if label: @@ -387,12 +357,7 @@ def generate_non_segmented_rows( if x_axis == STATE_ID: state = next( - ( - sta - for sta in state_details - if str(sta[STATE_ID]) == str(item) - ), - None, + (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None ) if state: @@ -400,12 +365,7 @@ def generate_non_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - ( - cyc - for cyc in cycle_details - if str(cyc[CYCLE_ID]) == str(item) - ), - None, + (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None ) if cycle: @@ -413,11 +373,7 @@ def generate_non_segmented_rows( if x_axis == MODULE_ID: module = next( - ( - mod - for mod in module_details - if str(mod[MODULE_ID]) == str(item) - ), + (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), None, ) @@ -426,10 +382,7 @@ def generate_non_segmented_rows( rows.append(tuple(row)) - row_zero = [ - row_mapping.get(x_axis, "X-Axis"), - row_mapping.get(y_axis, "Y-Axis"), - ] + row_zero = [row_mapping.get(x_axis, "X-Axis"), row_mapping.get(y_axis, "Y-Axis")] return [tuple(row_zero)] + rows diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py index 35d72812d..0752272e3 100644 --- a/apiserver/plane/bgtasks/deletion_task.py +++ b/apiserver/plane/bgtasks/deletion_task.py @@ -10,9 +10,7 @@ from celery import shared_task @shared_task -def soft_delete_related_objects( - app_label, model_name, instance_pk, using=None -): +def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): model_class = apps.get_model(app_label, model_name) instance = model_class.all_objects.get(pk=instance_pk) related_fields = instance._meta.get_fields() @@ -21,17 +19,15 @@ def soft_delete_related_objects( try: # Check if the field has CASCADE on delete if ( - hasattr(field.remote_field, "on_delete") - and field.remote_field.on_delete == models.CASCADE + not hasattr(field.remote_field, "on_delete") + or field.remote_field.on_delete == models.CASCADE ): if field.one_to_many: related_objects = getattr(instance, field.name).all() elif field.one_to_one: related_object = getattr(instance, field.name) related_objects = ( - [related_object] - if related_object is not None - else [] + [related_object] if related_object is not None else [] ) for obj in related_objects: @@ -49,7 +45,6 @@ def restore_related_objects(app_label, model_name, instance_pk, using=None): @shared_task def hard_delete(): - from plane.db.models import ( Workspace, Project, diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py index 21ca32afb..a3f95d0bc 100644 --- a/apiserver/plane/bgtasks/dummy_data_task.py +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -30,8 +30,8 @@ from plane.db.models import ( Page, ProjectPage, PageLabel, - Inbox, - InboxIssue, + Intake, + IntakeIssue, ) @@ -47,15 +47,11 @@ def create_project(workspace, user_id): : random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1) ].upper(), created_by_id=user_id, - inbox_view=True, + intake_view=True, ) # Add current member as project member - _ = ProjectMember.objects.create( - project=project, - member_id=user_id, - role=20, - ) + _ = ProjectMember.objects.create(project=project, member_id=user_id, role=20) return project @@ -88,24 +84,14 @@ def create_states(workspace, project, user_id): "group": "backlog", "default": True, }, - { - "name": "Todo", - "color": "#3A3A3A", - "sequence": 25000, - "group": "unstarted", - }, + {"name": "Todo", "color": "#3A3A3A", "sequence": 25000, "group": "unstarted"}, { "name": "In Progress", "color": "#F59E0B", "sequence": 35000, "group": "started", }, - { - "name": "Done", - "color": "#16A34A", - "sequence": 45000, - "group": "completed", - }, + {"name": "Done", "color": "#16A34A", "sequence": 45000, "group": "completed"}, { "name": "Cancelled", "color": "#EF4444", @@ -177,8 +163,7 @@ def create_cycles(workspace, project, user_id, cycle_count): # Ensure end_date is strictly after start_date if start_date is not None while start_date is not None and ( - end_date <= start_date - or (start_date, end_date) in used_date_ranges + end_date <= start_date or (start_date, end_date) in used_date_ranges ): end_date = fake.date_this_year() @@ -259,11 +244,7 @@ def create_pages(workspace, project, user_id, pages_count): # Add Page to project ProjectPage.objects.bulk_create( [ - ProjectPage( - page=page, - project=project, - workspace=workspace, - ) + ProjectPage(page=page, project=project, workspace=workspace) for page in pages ], batch_size=1000, @@ -274,26 +255,16 @@ def create_page_labels(workspace, project, user_id, pages_count): # labels labels = Label.objects.filter(project=project).values_list("id", flat=True) pages = random.sample( - list( - Page.objects.filter(projects__id=project.id).values_list( - "id", flat=True - ) - ), + list(Page.objects.filter(projects__id=project.id).values_list("id", flat=True)), int(pages_count / 2), ) # Bulk page labels bulk_page_labels = [] for page in pages: - for label in random.sample( - list(labels), random.randint(0, len(labels) - 1) - ): + for label in random.sample(list(labels), random.randint(0, len(labels) - 1)): bulk_page_labels.append( - PageLabel( - page_id=page, - label_id=label, - workspace=workspace, - ) + PageLabel(page_id=page, label_id=label, workspace=workspace) ) # Page labels @@ -318,9 +289,7 @@ def create_issues(workspace, project, user_id, issue_count): issues = [] # Get the maximum sequence_id - last_id = IssueSequence.objects.filter( - project=project, - ).aggregate( + last_id = IssueSequence.objects.filter(project=project).aggregate( largest=Max("sequence") )["largest"] @@ -328,8 +297,7 @@ def create_issues(workspace, project, user_id, issue_count): # Get the maximum sort order largest_sort_order = Issue.objects.filter( - project=project, - state_id=states[random.randint(0, len(states) - 1)], + project=project, state_id=states[random.randint(0, len(states) - 1)] ).aggregate(largest=Max("sort_order"))["largest"] largest_sort_order = ( @@ -370,9 +338,7 @@ def create_issues(workspace, project, user_id, issue_count): largest_sort_order = largest_sort_order + random.randint(0, 1000) last_id = last_id + 1 - issues = Issue.objects.bulk_create( - issues, ignore_conflicts=True, batch_size=1000 - ) + issues = Issue.objects.bulk_create(issues, ignore_conflicts=True, batch_size=1000) # Sequences _ = IssueSequence.objects.bulk_create( [ @@ -406,18 +372,16 @@ def create_issues(workspace, project, user_id, issue_count): return issues -def create_inbox_issues(workspace, project, user_id, inbox_issue_count): - issues = create_issues(workspace, project, user_id, inbox_issue_count) - inbox, create = Inbox.objects.get_or_create( - name="Inbox", - project=project, - is_default=True, +def create_intake_issues(workspace, project, user_id, intake_issue_count): + issues = create_issues(workspace, project, user_id, intake_issue_count) + intake, create = Intake.objects.get_or_create( + name="Intake", project=project, is_default=True ) - InboxIssue.objects.bulk_create( + IntakeIssue.objects.bulk_create( [ - InboxIssue( + IntakeIssue( issue=issue, - inbox=inbox, + intake=intake, status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]), snoozed_till=( datetime.now() + timedelta(days=random.randint(1, 30)) @@ -435,21 +399,18 @@ def create_inbox_issues(workspace, project, user_id, inbox_issue_count): def create_issue_parent(workspace, project, user_id, issue_count): - parent_count = issue_count / 4 - parent_issues = Issue.objects.filter(project=project).values_list( - "id", flat=True - )[: int(parent_count)] - sub_issues = Issue.objects.filter(project=project).exclude( - pk__in=parent_issues - )[: int(issue_count / 2)] + parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[ + : int(parent_count) + ] + sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[ + : int(issue_count / 2) + ] bulk_sub_issues = [] for sub_issue in sub_issues: - sub_issue.parent_id = parent_issues[ - random.randint(0, int(parent_count - 1)) - ] + sub_issue.parent_id = parent_issues[random.randint(0, int(parent_count - 1))] Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000) @@ -460,9 +421,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count): "member_id", flat=True ) issues = random.sample( - list( - Issue.objects.filter(project=project).values_list("id", flat=True) - ), + list(Issue.objects.filter(project=project).values_list("id", flat=True)), int(issue_count / 2), ) @@ -496,24 +455,17 @@ def create_issue_labels(workspace, project, user_id, issue_count): # ), # int(issue_count / 2), # ) - issues = list( - Issue.objects.filter(project=project).values_list("id", flat=True) - ) + issues = list(Issue.objects.filter(project=project).values_list("id", flat=True)) shuffled_labels = list(labels) # Bulk issue bulk_issue_labels = [] for issue in issues: random.shuffle(shuffled_labels) - for label in random.sample( - shuffled_labels, random.randint(0, 5) - ): + for label in random.sample(shuffled_labels, random.randint(0, 5)): bulk_issue_labels.append( IssueLabel( - issue_id=issue, - label_id=label, - project=project, - workspace=workspace, + issue_id=issue, label_id=label, project=project, workspace=workspace ) ) @@ -527,9 +479,7 @@ def create_cycle_issues(workspace, project, user_id, issue_count): # assignees cycles = Cycle.objects.filter(project=project).values_list("id", flat=True) issues = random.sample( - list( - Issue.objects.filter(project=project).values_list("id", flat=True) - ), + list(Issue.objects.filter(project=project).values_list("id", flat=True)), int(issue_count / 2), ) @@ -539,10 +489,7 @@ def create_cycle_issues(workspace, project, user_id, issue_count): cycle = cycles[random.randint(0, len(cycles) - 1)] bulk_cycle_issues.append( CycleIssue( - cycle_id=cycle, - issue_id=issue, - project=project, - workspace=workspace, + cycle_id=cycle, issue_id=issue, project=project, workspace=workspace ) ) @@ -554,18 +501,14 @@ def create_cycle_issues(workspace, project, user_id, issue_count): def create_module_issues(workspace, project, user_id, issue_count): # assignees - modules = Module.objects.filter(project=project).values_list( - "id", flat=True - ) + modules = Module.objects.filter(project=project).values_list("id", flat=True) # issues = random.sample( # list( # Issue.objects.filter(project=project).values_list("id", flat=True) # ), # int(issue_count / 2), # ) - issues = list( - Issue.objects.filter(project=project).values_list("id", flat=True) - ) + issues = list(Issue.objects.filter(project=project).values_list("id", flat=True)) shuffled_modules = list(modules) @@ -573,9 +516,7 @@ def create_module_issues(workspace, project, user_id, issue_count): bulk_module_issues = [] for issue in issues: random.shuffle(shuffled_modules) - for module in random.sample( - shuffled_modules, random.randint(0, 5) - ): + for module in random.sample(shuffled_modules, random.randint(0, 5)): bulk_module_issues.append( ModuleIssue( module_id=module, @@ -599,7 +540,7 @@ def create_dummy_data( cycle_count, module_count, pages_count, - inbox_issue_count, + intake_issue_count, ): workspace = Workspace.objects.get(slug=slug) @@ -610,9 +551,7 @@ def create_dummy_data( project = create_project(workspace=workspace, user_id=user_id) # create project members - create_project_members( - workspace=workspace, project=project, members=members - ) + create_project_members(workspace=workspace, project=project, members=members) # Create states create_states(workspace=workspace, project=project, user_id=user_id) @@ -622,90 +561,60 @@ def create_dummy_data( # create cycles create_cycles( - workspace=workspace, - project=project, - user_id=user_id, - cycle_count=cycle_count, + workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count ) # create modules create_modules( - workspace=workspace, - project=project, - user_id=user_id, - module_count=module_count, + workspace=workspace, project=project, user_id=user_id, module_count=module_count ) # create pages create_pages( - workspace=workspace, - project=project, - user_id=user_id, - pages_count=pages_count, + workspace=workspace, project=project, user_id=user_id, pages_count=pages_count ) # create page labels create_page_labels( - workspace=workspace, - project=project, - user_id=user_id, - pages_count=pages_count, + workspace=workspace, project=project, user_id=user_id, pages_count=pages_count ) # create issues create_issues( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) - # create inbox issues - create_inbox_issues( + # create intake issues + create_intake_issues( workspace=workspace, project=project, user_id=user_id, - inbox_issue_count=inbox_issue_count, + intake_issue_count=intake_issue_count, ) # create issue parent create_issue_parent( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) # create issue assignees create_issue_assignees( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) # create issue labels create_issue_labels( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) # create cycle issues create_cycle_issues( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) # create module issues create_module_issues( - workspace=workspace, - project=project, - user_id=user_id, - issue_count=issue_count, + workspace=workspace, project=project, user_id=user_id, issue_count=issue_count ) return diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 11ec91eb4..b0f75b5dc 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -75,9 +75,7 @@ def stack_email_notification(): for receiver_notification in receiver_notifications: payload.setdefault( receiver_notification.get("entity_identifier"), {} - ).setdefault( - str(receiver_notification.get("triggered_by_id")), [] - ).append( + ).setdefault(str(receiver_notification.get("triggered_by_id")), []).append( receiver_notification.get("data") ) # append processed notifications @@ -184,11 +182,7 @@ def send_email_notification( if acquire_lock(lock_id=lock_id): # get the redis instance ri = redis_instance() - base_api = ( - ri.get(str(issue_id)).decode() - if ri.get(str(issue_id)) - else None - ) + base_api = ri.get(str(issue_id)).decode() if ri.get(str(issue_id)) else None # Skip if base api is not present if not base_api: @@ -224,7 +218,7 @@ def send_email_notification( { "actor_comments": comment, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -241,7 +235,7 @@ def send_email_notification( { "actor_comments": mention, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -257,7 +251,7 @@ def send_email_notification( template_data.append( { "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -283,9 +277,7 @@ def send_email_notification( "name": issue.name, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", }, - "receiver": { - "email": receiver.email, - }, + "receiver": {"email": receiver.email}, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", "workspace": str(issue.project.workspace.slug), @@ -333,9 +325,7 @@ def send_email_notification( release_lock(lock_id=lock_id) return else: - logging.getLogger("plane").info( - "Duplicate email received skipping" - ) + logging.getLogger("plane").info("Duplicate email received skipping") return except (Issue.DoesNotExist, User.DoesNotExist): release_lock(lock_id=lock_id) diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py index 135ae1dd1..0629db93a 100644 --- a/apiserver/plane/bgtasks/event_tracking_task.py +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -17,10 +17,7 @@ def posthogConfiguration(): "key": "POSTHOG_API_KEY", "default": os.environ.get("POSTHOG_API_KEY", None), }, - { - "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", None), - }, + {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)}, ] ) if POSTHOG_API_KEY and POSTHOG_HOST: @@ -42,10 +39,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time): properties={ "event_id": uuid.uuid4().hex, "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, + "device_ctx": {"ip": ip, "user_agent": user_agent}, "medium": medium, "first_time": first_time, }, @@ -56,9 +50,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time): @shared_task -def workspace_invite_event( - user, email, user_agent, ip, event_name, accepted_from -): +def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): try: POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() @@ -70,10 +62,7 @@ def workspace_invite_event( properties={ "event_id": uuid.uuid4().hex, "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, + "device_ctx": {"ip": ip, "user_agent": user_agent}, "accepted_from": accepted_from, }, ) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index e671608d9..f7b19f00a 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -69,7 +69,9 @@ 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]}-{str(timezone.now().date())}.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: @@ -98,14 +100,10 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): presigned_url = presign_s3.generate_presigned_url( "get_object", - Params={ - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Key": file_name, - }, + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, ExpiresIn=expires_in, ) else: - # If endpoint url is present, use it if settings.AWS_S3_ENDPOINT_URL: s3 = boto3.client( @@ -129,16 +127,13 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): zip_file, settings.AWS_STORAGE_BUCKET_NAME, file_name, - ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, + ExtraArgs={"ContentType": "application/zip"}, ) # Generate presigned url for the uploaded file presigned_url = s3.generate_presigned_url( "get_object", - Params={ - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Key": file_name, - }, + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, ExpiresIn=expires_in, ) @@ -162,6 +157,8 @@ def generate_table_row(issue): issue["name"], issue["description_stripped"], issue["state__name"], + dateConverter(issue["start_date"]), + dateConverter(issue["target_date"]), issue["priority"], ( f"{issue['created_by__first_name']} {issue['created_by__last_name']}" @@ -195,6 +192,8 @@ def generate_json_row(issue): "Name": issue["name"], "Description": issue["description_stripped"], "State": issue["state__name"], + "Start Date": dateConverter(issue["start_date"]), + "Target Date": dateConverter(issue["target_date"]), "Priority": issue["priority"], "Created By": ( f"{issue['created_by__first_name']} {issue['created_by__last_name']}" @@ -258,11 +257,7 @@ def update_json_row(rows, row): def update_table_row(rows, row): matched_index = next( - ( - index - for index, existing_row in enumerate(rows) - if existing_row[0] == row[0] - ), + (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), None, ) @@ -286,9 +281,7 @@ def generate_csv(header, project_id, issues, files): """ Generate CSV export for all the passed issues. """ - rows = [ - header, - ] + rows = [header] for issue in issues: row = generate_table_row(issue) update_table_row(rows, row) @@ -315,9 +308,7 @@ def generate_xlsx(header, project_id, issues, files): @shared_task -def issue_export_task( - provider, workspace_id, project_ids, token_id, multiple, slug -): +def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug): try: exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance.status = "processing" @@ -332,14 +323,9 @@ def issue_export_task( project__project_projectmember__is_active=True, project__archived_at__isnull=True, ) - .select_related( - "project", "workspace", "state", "parent", "created_by" - ) + .select_related("project", "workspace", "state", "parent", "created_by") .prefetch_related( - "assignees", - "labels", - "issue_cycle__cycle", - "issue_module__module", + "assignees", "labels", "issue_cycle__cycle", "issue_module__module" ) .values( "id", @@ -350,6 +336,8 @@ def issue_export_task( "name", "description_stripped", "priority", + "start_date", + "target_date", "state__name", "created_at", "updated_at", @@ -378,6 +366,8 @@ def issue_export_task( "Name", "Description", "State", + "Start Date", + "Target Date", "Priority", "Created By", "Assignee", @@ -406,22 +396,12 @@ def issue_export_task( issues = workspace_issues.filter(project__id=project_id) exporter = EXPORTER_MAPPER.get(provider) if exporter is not None: - exporter( - header, - project_id, - issues, - files, - ) + exporter(header, project_id, issues, files) else: exporter = EXPORTER_MAPPER.get(provider) if exporter is not None: - exporter( - header, - workspace_id, - workspace_issues, - files, - ) + exporter(header, workspace_id, workspace_issues, files) zip_buffer = create_zip_file(files) upload_to_s3(zip_buffer, workspace_id, token_id, slug) diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index d408c6476..30b638c84 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -19,8 +19,7 @@ from plane.db.models import ExporterHistory def delete_old_s3_link(): # Get a list of keys and IDs to process expired_exporter_history = ExporterHistory.objects.filter( - Q(url__isnull=False) - & Q(created_at__lte=timezone.now() - timedelta(days=8)) + Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) ).values_list("key", "id") if settings.USE_MINIO: s3 = boto3.client( @@ -43,12 +42,8 @@ def delete_old_s3_link(): # Delete object from S3 if file_name: if settings.USE_MINIO: - s3.delete_object( - Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name - ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) else: - s3.delete_object( - Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name - ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) ExporterHistory.objects.filter(id=exporter_id).update(url=None) diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index e372355ef..b7b05df3b 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -1,4 +1,5 @@ # Python imports +import os from datetime import timedelta # Django imports @@ -13,16 +14,12 @@ from plane.db.models import FileAsset @shared_task -def delete_file_asset(): - # file assets to delete - file_assets_to_delete = FileAsset.objects.filter( - Q(is_deleted=True) - & Q(updated_at__lte=timezone.now() - timedelta(days=7)) - ) - - # Delete the file from storage and the file object from the database - for file_asset in file_assets_to_delete: - # Delete the file from storage - file_asset.asset.delete(save=False) - # Delete the file object - file_asset.delete() +def delete_unuploaded_file_asset(): + """This task deletes unuploaded file assets older than a certain number of days.""" + FileAsset.objects.filter( + Q( + created_at__lt=timezone.now() + - timedelta(days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7"))) + ) + & Q(is_uploaded=False) + ).delete() diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index f830eb1e2..6e8990ad1 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -18,7 +18,9 @@ from plane.utils.exception_logger import log_exception @shared_task def forgot_password(first_name, email, uidb64, token, current_site): try: - relative_link = f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" + relative_link = ( + f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" + ) abs_url = str(current_site) + relative_link ( @@ -39,9 +41,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): "email": email, } - html_content = render_to_string( - "emails/auth/forgot_password.html", context - ) + html_content = render_to_string("emails/auth/forgot_password.html", context) text_content = strip_tags(html_content) diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index 0cee9baef..e0e2ea84e 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -31,6 +31,7 @@ from plane.db.models import ( from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception from plane.bgtasks.webhook_task import webhook_activity +from plane.utils.issue_relation_mapper import get_inverse_relation # Track Changes in name @@ -146,12 +147,8 @@ def track_parent( project_id=project_id, workspace_id=workspace_id, comment="updated the parent issue to", - old_identifier=( - old_parent.id if old_parent is not None else None - ), - new_identifier=( - new_parent.id if new_parent is not None else None - ), + old_identifier=(old_parent.id if old_parent is not None else None), + new_identifier=(new_parent.id if new_parent is not None else None), epoch=epoch, ) ) @@ -198,9 +195,7 @@ def track_state( ): if current_instance.get("state_id") != requested_data.get("state_id"): new_state = State.objects.get(pk=requested_data.get("state_id", None)) - old_state = State.objects.get( - pk=current_instance.get("state_id", None) - ) + old_state = State.objects.get(pk=current_instance.get("state_id", None)) issue_activities.append( IssueActivity( @@ -231,9 +226,7 @@ def track_target_date( issue_activities, epoch, ): - if current_instance.get("target_date") != requested_data.get( - "target_date" - ): + if current_instance.get("target_date") != requested_data.get("target_date"): issue_activities.append( IssueActivity( issue_id=issue_id, @@ -305,12 +298,8 @@ def track_labels( issue_activities, epoch, ): - requested_labels = set( - [str(lab) for lab in requested_data.get("label_ids", [])] - ) - current_labels = set( - [str(lab) for lab in current_instance.get("label_ids", [])] - ) + requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])]) + current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])]) added_labels = requested_labels - current_labels dropped_labels = current_labels - requested_labels @@ -444,9 +433,7 @@ def track_estimate_points( issue_activities, epoch, ): - if current_instance.get("estimate_point") != requested_data.get( - "estimate_point" - ): + if current_instance.get("estimate_point") != requested_data.get("estimate_point"): old_estimate = ( EstimatePoint.objects.filter( pk=current_instance.get("estimate_point") @@ -497,9 +484,7 @@ def track_archive_at( issue_activities, epoch, ): - if current_instance.get("archived_at") != requested_data.get( - "archived_at" - ): + if current_instance.get("archived_at") != requested_data.get("archived_at"): if requested_data.get("archived_at") is None: issue_activities.append( IssueActivity( @@ -593,9 +578,7 @@ def create_issue_activity( issue_activity.created_at = issue.created_at issue_activity.actor_id = issue.created_by_id issue_activity.save(update_fields=["created_at", "actor_id"]) - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data.get("assignee_ids") is not None: track_assignees( requested_data, @@ -634,9 +617,7 @@ def update_issue_activity( "closed_to": track_closed_to, } - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -690,9 +671,7 @@ def create_comment_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -724,16 +703,12 @@ def update_comment_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance.get("comment_html") != requested_data.get( - "comment_html" - ): + if current_instance.get("comment_html") != requested_data.get("comment_html"): issue_activities.append( IssueActivity( issue_id=issue_id, @@ -787,18 +762,14 @@ def create_cycle_issue_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) # Updated Records: updated_records = current_instance.get("updated_cycle_issues", []) - created_records = json.loads( - current_instance.get("created_cycle_issues", []) - ) + created_records = json.loads(current_instance.get("created_cycle_issues", [])) for updated_record in updated_records: old_cycle = Cycle.objects.filter( @@ -867,9 +838,7 @@ def delete_cycle_issue_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -910,9 +879,7 @@ def create_module_issue_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None module = Module.objects.filter(pk=requested_data.get("module_id")).first() issue = Issue.objects.filter(pk=issue_id).first() if issue: @@ -945,9 +912,7 @@ def delete_module_issue_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -987,9 +952,7 @@ def create_link_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1020,9 +983,7 @@ def update_link_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1086,9 +1047,7 @@ def create_attachment_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1143,9 +1102,7 @@ def create_issue_reaction_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: issue_reaction = ( IssueReaction.objects.filter( @@ -1217,9 +1174,7 @@ def create_comment_reaction_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: comment_reaction_id, comment_id = ( CommentReaction.objects.filter( @@ -1230,9 +1185,7 @@ def create_comment_reaction_activity( .values_list("id", "comment__id") .first() ) - comment = IssueComment.objects.get( - pk=comment_id, project_id=project_id - ) + comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) if ( comment is not None and comment_reaction_id is not None @@ -1306,9 +1259,7 @@ def create_issue_vote_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("vote") is not None: issue_activities.append( IssueActivity( @@ -1370,9 +1321,7 @@ def create_issue_relation_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1394,6 +1343,7 @@ def create_issue_relation_activity( epoch=epoch, ) ) + inverse_relation = get_inverse_relation(requested_data.get("relation_type")) issue = Issue.objects.get(pk=issue_id) issue_activities.append( IssueActivity( @@ -1402,19 +1352,10 @@ def create_issue_relation_activity( verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field=( - "blocking" - if requested_data.get("relation_type") == "blocked_by" - else ( - "blocked_by" - if requested_data.get("relation_type") - == "blocking" - else requested_data.get("relation_type") - ) - ), + field=inverse_relation, project_id=project_id, workspace_id=workspace_id, - comment=f'added {"blocking" if requested_data.get("relation_type") == "blocked_by" else ("blocked_by" if requested_data.get("relation_type") == "blocking" else requested_data.get("relation_type")),} relation', + comment=f"added {inverse_relation} relation", old_identifier=issue_id, epoch=epoch, ) @@ -1431,9 +1372,7 @@ def delete_issue_relation_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1513,9 +1452,7 @@ def update_draft_issue_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1572,7 +1509,7 @@ def delete_draft_issue_activity( ) -def create_inbox_activity( +def create_intake_activity( requested_data, current_instance, issue_id, @@ -1582,9 +1519,7 @@ def create_inbox_activity( issue_activities, epoch, ): - requested_data = ( - json.loads(requested_data) if requested_data is not None else None - ) + requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1601,8 +1536,8 @@ def create_inbox_activity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment="updated the inbox status", - field="inbox", + comment="updated the intake status", + field="intake", verb=requested_data.get("status"), actor_id=actor_id, epoch=epoch, @@ -1625,7 +1560,7 @@ def issue_activity( subscriber=True, notification=False, origin=None, - inbox=None, + intake=None, ): try: issue_activities = [] @@ -1673,7 +1608,7 @@ def issue_activity( "issue_draft.activity.created": create_draft_issue_activity, "issue_draft.activity.updated": update_draft_issue_activity, "issue_draft.activity.deleted": delete_draft_issue_activity, - "inbox.activity.created": create_inbox_activity, + "intake.activity.created": create_intake_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1690,9 +1625,7 @@ def issue_activity( ) # Save all the values to database - issue_activities_created = IssueActivity.objects.bulk_create( - issue_activities - ) + issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) # Post the updates to segway for integrations and webhooks if len(issue_activities_created): for activity in issue_activities_created: @@ -1700,28 +1633,26 @@ def issue_activity( event=( "issue_comment" if activity.field == "comment" - else "inbox_issue" if inbox else "issue" + else "intake_issue" + if intake + else "issue" ), event_id=( activity.issue_comment_id if activity.field == "comment" - else inbox if inbox else activity.issue_id + else intake + if intake + else activity.issue_id ), verb=activity.verb, field=( - "description" - if activity.field == "comment" - else activity.field + "description" if activity.field == "comment" else activity.field ), old_value=( - activity.old_value - if activity.old_value != "" - else None + activity.old_value if activity.old_value != "" else None ), new_value=( - activity.new_value - if activity.new_value != "" - else None + activity.new_value if activity.new_value != "" else None ), actor_id=activity.actor_id, current_site=origin, @@ -1738,9 +1669,7 @@ def issue_activity( project_id=project_id, subscriber=subscriber, issue_activities_created=json.dumps( - IssueActivitySerializer( - issue_activities_created, many=True - ).data, + IssueActivitySerializer(issue_activities_created, many=True).data, cls=DjangoJSONEncoder, ), requested_data=requested_data, diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 8e648c16b..68f3d32da 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -35,28 +35,24 @@ def archive_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=( - timezone.now() - timedelta(days=archive_in * 30) - ), + updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)), state__group__in=["completed", "cancelled"], ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( - Q(issue_inbox__status=1) - | Q(issue_inbox__status=-1) - | Q(issue_inbox__status=2) - | Q(issue_inbox__isnull=True) + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True) ) # Check if Issues @@ -78,10 +74,7 @@ def archive_old_issues(): issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps( - { - "archived_at": str(archive_at), - "automation": True, - } + {"archived_at": str(archive_at), "automation": True} ), actor_id=str(project.created_by_id), issue_id=issue.id, @@ -115,36 +108,30 @@ def close_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=( - timezone.now() - timedelta(days=close_in * 30) - ), + updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)), state__group__in=["backlog", "unstarted", "started"], ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( - Q(issue_inbox__status=1) - | Q(issue_inbox__status=-1) - | Q(issue_inbox__status=2) - | Q(issue_inbox__isnull=True) + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True) ) # Check if Issues if issues: if project.default_state is None: - close_state = State.objects.filter( - group="cancelled" - ).first() + close_state = State.objects.filter(group="cancelled").first() else: close_state = project.default_state diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 7be0ae9f8..848ea623f 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -32,9 +32,7 @@ def magic_link(email, key, token, current_site): subject = f"Your unique Plane login code is {token}" context = {"code": token, "email": email} - html_content = render_to_string( - "emails/auth/magic_signin.html", context - ) + html_content = render_to_string("emails/auth/magic_signin.html", context) text_content = strip_tags(html_content) connection = get_connection( diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 44f325015..ade247909 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -1,6 +1,8 @@ # Python imports import json import uuid +from uuid import UUID + # Module imports from plane.db.models import ( @@ -16,7 +18,9 @@ from plane.db.models import ( IssueComment, IssueActivity, UserNotificationPreference, + ProjectMember, ) +from django.db.models import Subquery # Third Party imports from celery import shared_task @@ -40,9 +44,7 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention): ) IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) - IssueMention.objects.filter( - issue=issue, mention__in=removed_mention - ).delete() + IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete() def get_new_mentions(requested_instance, current_instance): @@ -89,18 +91,17 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification if ( not IssueSubscriber.objects.filter( - issue_id=issue_id, - subscriber_id=mention_id, - project_id=project_id, + issue_id=issue_id, subscriber_id=mention_id, project_id=project_id ).exists() and not IssueAssignee.objects.filter( - project_id=project_id, - issue_id=issue_id, - assignee_id=mention_id, + project_id=project_id, issue_id=issue_id, assignee_id=mention_id ).exists() and not Issue.objects.filter( project_id=project_id, pk=issue_id, created_by_id=mention_id ).exists() + and ProjectMember.objects.filter( + project_id=project_id, member_id=mention_id, is_active=True + ).exists() ): project = Project.objects.get(pk=project_id) @@ -124,13 +125,9 @@ def extract_mentions(issue_instance): data = json.loads(issue_instance) html = data.get("description_html") soup = BeautifulSoup(html, "html.parser") - mention_tags = soup.find_all( - "mention-component", attrs={"target": "users"} - ) + mention_tags = soup.find_all("mention-component", attrs={"target": "users"}) - mentions = [ - mention_tag["entity_identifier"] for mention_tag in mention_tags - ] + mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags] return list(set(mentions)) except Exception: @@ -142,9 +139,7 @@ def extract_comment_mentions(comment_value): try: mentions = [] soup = BeautifulSoup(comment_value, "html.parser") - mentions_tags = soup.find_all( - "mention-component", attrs={"target": "users"} - ) + mentions_tags = soup.find_all("mention-component", attrs={"target": "users"}) for mention_tag in mentions_tags: mentions.append(mention_tag["entity_identifier"]) return list(set(mentions)) @@ -167,13 +162,7 @@ def get_new_comment_mentions(new_value, old_value): def create_mention_notification( - project, - notification_comment, - issue, - actor_id, - mention_id, - issue_id, - activity, + project, notification_comment, issue, actor_id, mention_id, issue_id, activity ): return Notification( workspace=project.workspace, @@ -257,27 +246,30 @@ def notifications( 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers """ + # get the list of active project members + project_members = ProjectMember.objects.filter( + project_id=project_id, is_active=True + ).values_list("member_id", flat=True) + # Get new mentions from the newer instance new_mentions = get_new_mentions( - requested_instance=requested_data, - current_instance=current_instance, + requested_instance=requested_data, current_instance=current_instance ) + + new_mentions = [ + str(mention) for mention in new_mentions if mention in set(project_members) + ] removed_mention = get_removed_mentions( - requested_instance=requested_data, - current_instance=current_instance, + requested_instance=requested_data, current_instance=current_instance ) comment_mentions = [] all_comment_mentions = [] # Get New Subscribers from the mentions of the newer instance - requested_mentions = extract_mentions( - issue_instance=requested_data - ) + requested_mentions = extract_mentions(issue_instance=requested_data) mention_subscribers = extract_mentions_as_subscribers( - project_id=project_id, - issue_id=issue_id, - mentions=requested_mentions, + project_id=project_id, issue_id=issue_id, mentions=requested_mentions ) for issue_activity in issue_activities_created: @@ -297,11 +289,14 @@ def notifications( new_value=issue_comment_new_value, ) comment_mentions = comment_mentions + new_comment_mentions + comment_mentions = [ + mention + for mention in comment_mentions + if UUID(mention) in set(project_members) + ] comment_mention_subscribers = extract_mentions_as_subscribers( - project_id=project_id, - issue_id=issue_id, - mentions=all_comment_mentions, + project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions ) """ We will not send subscription activity notification to the below mentioned user sets @@ -313,12 +308,12 @@ def notifications( # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # issue_subscribers = list( IssueSubscriber.objects.filter( - project_id=project_id, issue_id=issue_id + project_id=project_id, + issue_id=issue_id, + subscriber__in=Subquery(project_members), ) .exclude( - subscriber_id__in=list( - new_mentions + comment_mentions + [actor_id] - ) + subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]) ) .values_list("subscriber", flat=True) ) @@ -329,9 +324,7 @@ def notifications( # add the user to issue subscriber try: _ = IssueSubscriber.objects.get_or_create( - project_id=project_id, - issue_id=issue_id, - subscriber_id=actor_id, + project_id=project_id, issue_id=issue_id, subscriber_id=actor_id ) except Exception: pass @@ -339,12 +332,12 @@ def notifications( project = Project.objects.get(pk=project_id) issue_assignees = IssueAssignee.objects.filter( - issue_id=issue_id, project_id=project_id + issue_id=issue_id, + project_id=project_id, + assignee__in=Subquery(project_members), ).values_list("assignee", flat=True) - issue_subscribers = list( - set(issue_subscribers) - {uuid.UUID(actor_id)} - ) + issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) for subscriber in issue_subscribers: if issue.created_by_id and issue.created_by_id == subscriber: @@ -357,16 +350,11 @@ def notifications( else: sender = "in_app:issue_activities:subscribed" - preference = UserNotificationPreference.objects.get( - user_id=subscriber - ) + preference = UserNotificationPreference.objects.get(user_id=subscriber) for issue_activity in issue_activities_created: # If activity done in blocking then blocked by email should not go - if ( - issue_activity.get("issue_detail").get("id") - != issue_id - ): + if issue_activity.get("issue_detail").get("id") != issue_id: continue # Do not send notification for description update @@ -391,8 +379,7 @@ def notifications( ): send_email = True elif ( - issue_activity.get("field") == "comment" - and preference.comment + issue_activity.get("field") == "comment" and preference.comment ): send_email = True elif preference.property_change: @@ -427,9 +414,7 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, @@ -438,35 +423,21 @@ def notifications( "id": str(issue_activity.get("id")), "verb": str(issue_activity.get("verb")), "field": str(issue_activity.get("field")), - "actor": str( - issue_activity.get("actor_id") - ), - "new_value": str( - issue_activity.get("new_value") - ), - "old_value": str( - issue_activity.get("old_value") - ), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), "issue_comment": str( issue_comment.comment_stripped if issue_comment is not None else "" ), "old_identifier": ( - str( - issue_activity.get( - "old_identifier" - ) - ) + str(issue_activity.get("old_identifier")) if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get( - "new_identifier" - ) - ) + str(issue_activity.get("new_identifier")) if issue_activity.get("new_identifier") else None ), @@ -486,9 +457,7 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "project_id": str(issue.project.id), "workspace_slug": str( issue.project.workspace.slug @@ -499,15 +468,9 @@ def notifications( }, "issue_activity": { "id": str(issue_activity.get("id")), - "verb": str( - issue_activity.get("verb") - ), - "field": str( - issue_activity.get("field") - ), - "actor": str( - issue_activity.get("actor_id") - ), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str(issue_activity.get("actor_id")), "new_value": str( issue_activity.get("new_value") ), @@ -520,25 +483,13 @@ def notifications( else "" ), "old_identifier": ( - str( - issue_activity.get( - "old_identifier" - ) - ) - if issue_activity.get( - "old_identifier" - ) + str(issue_activity.get("old_identifier")) + if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get( - "new_identifier" - ) - ) - if issue_activity.get( - "new_identifier" - ) + str(issue_activity.get("new_identifier")) + if issue_activity.get("new_identifier") else None ), "activity_time": issue_activity.get( @@ -594,26 +545,18 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, - "project_id": str( - issue.project.id - ), + "project_id": str(issue.project.id), "workspace_slug": str( issue.project.workspace.slug ), }, "issue_activity": { - "id": str( - issue_activity.get("id") - ), - "verb": str( - issue_activity.get("verb") - ), + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), "field": str("mention"), "actor": str( issue_activity.get("actor_id") @@ -626,24 +569,16 @@ def notifications( ), "old_identifier": ( str( - issue_activity.get( - "old_identifier" - ) - ) - if issue_activity.get( - "old_identifier" + issue_activity.get("old_identifier") ) + if issue_activity.get("old_identifier") else None ), "new_identifier": ( str( - issue_activity.get( - "new_identifier" - ) - ) - if issue_activity.get( - "new_identifier" + issue_activity.get("new_identifier") ) + if issue_activity.get("new_identifier") else None ), "activity_time": issue_activity.get( @@ -679,9 +614,7 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, @@ -695,32 +628,16 @@ def notifications( "verb": str(last_activity.verb), "field": str(last_activity.field), "actor": str(last_activity.actor_id), - "new_value": str( - last_activity.new_value - ), - "old_value": str( - last_activity.old_value - ), + "new_value": str(last_activity.new_value), + "old_value": str(last_activity.old_value), "old_identifier": ( - str( - issue_activity.get( - "old_identifier" - ) - ) - if issue_activity.get( - "old_identifier" - ) + str(issue_activity.get("old_identifier")) + if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get( - "new_identifier" - ) - ) - if issue_activity.get( - "new_identifier" - ) + str(issue_activity.get("new_identifier")) + if issue_activity.get("new_identifier") else None ), }, @@ -738,9 +655,7 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, @@ -749,35 +664,21 @@ def notifications( "id": str(last_activity.id), "verb": str(last_activity.verb), "field": "mention", - "actor": str( - last_activity.actor_id - ), - "new_value": str( - last_activity.new_value - ), - "old_value": str( - last_activity.old_value - ), + "actor": str(last_activity.actor_id), + "new_value": str(last_activity.new_value), + "old_value": str(last_activity.old_value), "old_identifier": ( str( - issue_activity.get( - "old_identifier" - ) - ) - if issue_activity.get( - "old_identifier" + issue_activity.get("old_identifier") ) + if issue_activity.get("old_identifier") else None ), "new_identifier": ( str( - issue_activity.get( - "new_identifier" - ) - ) - if issue_activity.get( - "new_identifier" + issue_activity.get("new_identifier") ) + if issue_activity.get("new_identifier") else None ), "activity_time": str( @@ -817,27 +718,17 @@ def notifications( "state_group": issue.state.group, }, "issue_activity": { - "id": str( - issue_activity.get("id") - ), - "verb": str( - issue_activity.get("verb") - ), + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), "field": str("mention"), "actor": str( - issue_activity.get( - "actor_id" - ) + issue_activity.get("actor_id") ), "new_value": str( - issue_activity.get( - "new_value" - ) + issue_activity.get("new_value") ), "old_value": str( - issue_activity.get( - "old_value" - ) + issue_activity.get("old_value") ), "old_identifier": ( str( @@ -878,9 +769,7 @@ def notifications( removed_mention=removed_mention, ) # Bulk create notifications - Notification.objects.bulk_create( - bulk_notifications, batch_size=100 - ) + Notification.objects.bulk_create(bulk_notifications, batch_size=100) EmailNotificationLog.objects.bulk_create( bulk_email_logs, batch_size=100, ignore_conflicts=True ) diff --git a/apiserver/plane/bgtasks/page_version_task.py b/apiserver/plane/bgtasks/page_version_task.py index 628fe62b7..7a5f94c9e 100644 --- a/apiserver/plane/bgtasks/page_version_task.py +++ b/apiserver/plane/bgtasks/page_version_task.py @@ -10,20 +10,14 @@ from plane.utils.exception_logger import log_exception @shared_task -def page_version( - page_id, - existing_instance, - user_id, -): +def page_version(page_id, existing_instance, user_id): try: # Get the page page = Page.objects.get(id=page_id) # Get the current instance current_instance = ( - json.loads(existing_instance) - if existing_instance is not None - else {} + json.loads(existing_instance) if existing_instance is not None else {} ) # Create a version if description_html is updated diff --git a/apiserver/plane/bgtasks/recent_visited_task.py b/apiserver/plane/bgtasks/recent_visited_task.py index 9569abf5e..e8e3eb60f 100644 --- a/apiserver/plane/bgtasks/recent_visited_task.py +++ b/apiserver/plane/bgtasks/recent_visited_task.py @@ -10,9 +10,7 @@ from plane.utils.exception_logger import log_exception @shared_task -def recent_visited_task( - entity_name, entity_identifier, user_id, project_id, slug -): +def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slug): try: workspace = Workspace.objects.get(slug=slug) recent_visited = UserRecentVisit.objects.filter( @@ -27,7 +25,6 @@ def recent_visited_task( recent_visited.visited_at = timezone.now() recent_visited.save(update_fields=["visited_at"]) else: - recent_visited_count = UserRecentVisit.objects.filter( user_id=user_id, workspace_id=workspace.id ).count() @@ -51,9 +48,7 @@ def recent_visited_task( ) recent_activity.created_by_id = user_id recent_activity.updated_by_id = user_id - recent_activity.save( - update_fields=["created_by_id", "updated_by_id"] - ) + recent_activity.save(update_fields=["created_by_id", "updated_by_id"]) return except Exception as e: diff --git a/apiserver/plane/bgtasks/storage_metadata_task.py b/apiserver/plane/bgtasks/storage_metadata_task.py new file mode 100644 index 000000000..f5daf73ba --- /dev/null +++ b/apiserver/plane/bgtasks/storage_metadata_task.py @@ -0,0 +1,28 @@ +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import FileAsset +from plane.settings.storage import S3Storage +from plane.utils.exception_logger import log_exception + + +@shared_task +def get_asset_object_metadata(asset_id): + try: + # Get the asset + asset = FileAsset.objects.get(pk=asset_id) + # Create an instance of the S3 storage + storage = S3Storage() + # Get the storage + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + # Save the asset + asset.save(update_fields=["storage_metadata"]) + return + except FileAsset.DoesNotExist: + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apiserver/plane/bgtasks/user_activation_email_task.py index 2fdfc4ddb..23f0e1d01 100644 --- a/apiserver/plane/bgtasks/user_activation_email_task.py +++ b/apiserver/plane/bgtasks/user_activation_email_task.py @@ -22,15 +22,10 @@ def user_activation_email(current_site, user_id): 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", - } + 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 - ) + html_content = render_to_string("emails/user/user_activation.html", context) text_content = strip_tags(html_content) # Configure email connection from the database diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apiserver/plane/bgtasks/user_deactivation_email_task.py index fa8523d50..9425dc324 100644 --- a/apiserver/plane/bgtasks/user_deactivation_email_task.py +++ b/apiserver/plane/bgtasks/user_deactivation_email_task.py @@ -22,15 +22,10 @@ def user_deactivation_email(current_site, user_id): 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", - } + 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 - ) + html_content = render_to_string("emails/user/user_deactivation.html", context) text_content = strip_tags(html_content) # Configure email connection from the database diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 7614c4b2f..d5d8b0043 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -27,7 +27,7 @@ from plane.api.serializers import ( ModuleSerializer, ProjectSerializer, UserLiteSerializer, - InboxIssueSerializer, + IntakeIssueSerializer, ) from plane.db.models import ( Cycle, @@ -40,7 +40,7 @@ from plane.db.models import ( User, Webhook, WebhookLog, - InboxIssue, + IntakeIssue, ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception @@ -54,7 +54,7 @@ SERIALIZER_MAPPER = { "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, "user": UserLiteSerializer, - "inbox_issue": InboxIssueSerializer, + "intake_issue": IntakeIssueSerializer, } MODEL_MAPPER = { @@ -66,7 +66,7 @@ MODEL_MAPPER = { "module_issue": ModuleIssue, "issue_comment": IssueComment, "user": User, - "inbox_issue": InboxIssue, + "intake_issue": IntakeIssue, } @@ -131,12 +131,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): headers["X-Plane-Signature"] = signature # Send the webhook event - response = requests.post( - webhook.url, - headers=headers, - json=payload, - timeout=30, - ) + response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) # Log the webhook request WebhookLog.objects.create( @@ -190,9 +185,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): @shared_task -def send_webhook_deactivation_email( - webhook_id, receiver_id, current_site, reason -): +def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): # Get email configurations ( EMAIL_HOST, @@ -207,9 +200,7 @@ def send_webhook_deactivation_email( receiver = User.objects.get(pk=receiver_id) webhook = Webhook.objects.get(pk=webhook_id) subject = "Webhook Deactivated" - message = ( - f"Webhook {webhook.url} has been deactivated due to failed requests." - ) + message = f"Webhook {webhook.url} has been deactivated due to failed requests." # Send the mail context = { @@ -256,14 +247,7 @@ def send_webhook_deactivation_email( retry_jitter=True, ) def webhook_send_task( - self, - webhook, - slug, - event, - event_data, - action, - current_site, - activity, + self, webhook, slug, event, event_data, action, current_site, activity ): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -315,12 +299,7 @@ def webhook_send_task( headers["X-Plane-Signature"] = signature # Send the webhook event - response = requests.post( - webhook.url, - headers=headers, - json=payload, - timeout=30, - ) + response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) # Log the webhook request WebhookLog.objects.create( @@ -408,10 +387,7 @@ def webhook_activity( webhook=webhook.id, slug=slug, event=event, - event_data=get_model_data( - event=event, - event_id=event_id, - ), + event_data=get_model_data(event=event, event_id=event_id), action=verb, current_site=current_site, activity={ @@ -436,13 +412,7 @@ def webhook_activity( @shared_task def model_activity( - model_name, - model_id, - requested_data, - current_instance, - actor_id, - slug, - origin=None, + model_name, model_id, requested_data, current_instance, actor_id, slug, origin=None ): """Function takes in two json and computes differences between keys of both the json""" if current_instance is None: diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 459cb8ed6..740c16b1d 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -25,24 +25,24 @@ app.conf.beat_schedule = { "schedule": crontab(hour=0, minute=0), }, "check-every-day-to-delete-file-asset": { - "task": "plane.bgtasks.file_asset_task.delete_file_asset", + "task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset", "schedule": crontab(hour=0, minute=0), }, "check-every-five-minutes-to-send-email-notifications": { "task": "plane.bgtasks.email_notification_task.stack_email_notification", "schedule": crontab(minute="*/5"), }, - "check-every-day-to-delete-api-logs": { - "task": "plane.bgtasks.api_logs_task.delete_api_logs", - "schedule": crontab(hour=0, minute=0), - }, "check-every-day-to-delete-hard-delete": { "task": "plane.bgtasks.deletion_task.hard_delete", "schedule": crontab(hour=0, minute=0), }, + "check-every-day-to-delete-api-logs": { + "task": "plane.bgtasks.api_logs_task.delete_api_logs", + "schedule": crontab(hour=0, minute=0), + }, "run-every-6-hours-for-instance-trace": { "task": "plane.license.bgtasks.tracer.instance_traces", - "schedule": crontab(hour="*/6"), + "schedule": crontab(hour="*/6", minute=0), }, } diff --git a/apiserver/plane/db/management/commands/clear_cache.py b/apiserver/plane/db/management/commands/clear_cache.py index c1908eee7..c9189ca32 100644 --- a/apiserver/plane/db/management/commands/clear_cache.py +++ b/apiserver/plane/db/management/commands/clear_cache.py @@ -8,18 +8,14 @@ class Command(BaseCommand): def add_arguments(self, parser): # Positional argument - parser.add_argument( - "--key", type=str, nargs="?", help="Key to clear cache" - ) + parser.add_argument("--key", type=str, nargs="?", help="Key to clear cache") def handle(self, *args, **options): try: if options["key"]: cache.delete(options["key"]) self.stdout.write( - self.style.SUCCESS( - f"Cache Cleared for key: {options['key']}" - ) + self.style.SUCCESS(f"Cache Cleared for key: {options['key']}") ) return diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py index bdd0b7014..838edd6c6 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -1,67 +1,41 @@ # Python imports +import os import boto3 -import json from botocore.exceptions import ClientError # Django imports from django.core.management import BaseCommand -from django.conf import settings class Command(BaseCommand): help = "Create the default bucket for the instance" - def set_bucket_public_policy(self, s3_client, bucket_name): - public_policy = { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"], - } - ], - } - - try: - s3_client.put_bucket_policy( - Bucket=bucket_name, Policy=json.dumps(public_policy) - ) - self.stdout.write( - self.style.SUCCESS( - f"Public read access policy set for bucket '{bucket_name}'." - ) - ) - except ClientError as e: - self.stdout.write( - self.style.ERROR( - f"Error setting public read access policy: {e}" - ) - ) - def handle(self, *args, **options): # Create a session using the credentials from Django settings try: - session = boto3.session.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get("AWS_S3_ENDPOINT_URL"), # MinIO endpoint + aws_access_key_id=os.environ.get( + "AWS_ACCESS_KEY_ID" + ), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), ) - # Create an S3 client using the session - s3_client = session.client( - "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL - ) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") self.stdout.write(self.style.NOTICE("Checking bucket...")) - # Check if the bucket exists s3_client.head_bucket(Bucket=bucket_name) - - self.set_bucket_public_policy(s3_client, bucket_name) + # If the bucket exists, print a success message + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' exists.")) + return except ClientError as e: error_code = int(e.response["Error"]["Code"]) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") if error_code == 404: # Bucket does not exist, create it self.stdout.write( @@ -76,13 +50,14 @@ class Command(BaseCommand): f"Bucket '{bucket_name}' created successfully." ) ) - self.set_bucket_public_policy(s3_client, bucket_name) + + # Handle the exception if the bucket creation fails except ClientError as create_error: self.stdout.write( - self.style.ERROR( - f"Failed to create bucket: {create_error}" - ) + self.style.ERROR(f"Failed to create bucket: {create_error}") ) + + # Handle the exception if access to the bucket is forbidden elif error_code == 403: # Access to the bucket is forbidden self.stdout.write( @@ -92,9 +67,7 @@ class Command(BaseCommand): ) else: # Another ClientError occurred - self.stdout.write( - self.style.ERROR(f"Failed to check bucket: {e}") - ) + self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) except Exception as ex: # Handle any other exception self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apiserver/plane/db/management/commands/create_dummy_data.py b/apiserver/plane/db/management/commands/create_dummy_data.py index f71d90f0e..3eedc390c 100644 --- a/apiserver/plane/db/management/commands/create_dummy_data.py +++ b/apiserver/plane/db/management/commands/create_dummy_data.py @@ -10,7 +10,6 @@ class Command(BaseCommand): help = "Create dump issues, cycles etc. for a project in a given workspace" def handle(self, *args: Any, **options: Any) -> str | None: - try: workspace_name = input("Workspace Name: ") workspace_slug = input("Workspace slug: ") @@ -23,10 +22,7 @@ class Command(BaseCommand): creator = input("Your email: ") - if ( - creator == "" - or not User.objects.filter(email=creator).exists() - ): + if creator == "" or not User.objects.filter(email=creator).exists(): raise CommandError( "User email is required and should have signed in plane" ) @@ -37,23 +33,15 @@ class Command(BaseCommand): members = members.split(",") if members != "" else [] # Create workspace workspace = Workspace.objects.create( - slug=workspace_slug, - name=workspace_name, - owner=user, + slug=workspace_slug, name=workspace_name, owner=user ) # Create workspace member - WorkspaceMember.objects.create( - workspace=workspace, role=20, member=user - ) + WorkspaceMember.objects.create(workspace=workspace, role=20, member=user) user_ids = User.objects.filter(email__in=members) _ = WorkspaceMember.objects.bulk_create( [ - WorkspaceMember( - workspace=workspace, - member=user_id, - role=20, - ) + WorkspaceMember(workspace=workspace, member=user_id, role=20) for user_id in user_ids ], ignore_conflicts=True, @@ -67,8 +55,8 @@ class Command(BaseCommand): cycle_count = int(input("Number of cycles to be created: ")) module_count = int(input("Number of modules to be created: ")) pages_count = int(input("Number of pages to be created: ")) - inbox_issue_count = int( - input("Number of inbox issues to be created: ") + intake_issue_count = int( + input("Number of intake issues to be created: ") ) from plane.bgtasks.dummy_data_task import create_dummy_data @@ -81,15 +69,11 @@ class Command(BaseCommand): cycle_count=cycle_count, module_count=module_count, pages_count=pages_count, - inbox_issue_count=inbox_issue_count, + intake_issue_count=intake_issue_count, ) - self.stdout.write( - self.style.SUCCESS("Data is pushed to the queue") - ) + self.stdout.write(self.style.SUCCESS("Data is pushed to the queue")) return except Exception as e: - self.stdout.write( - self.style.ERROR(f"Command errored out {str(e)}") - ) + self.stdout.write(self.style.ERROR(f"Command errored out {str(e)}")) return diff --git a/apiserver/plane/db/management/commands/create_instance_admin.py b/apiserver/plane/db/management/commands/create_instance_admin.py index 21f79c15e..8b957f7fc 100644 --- a/apiserver/plane/db/management/commands/create_instance_admin.py +++ b/apiserver/plane/db/management/commands/create_instance_admin.py @@ -11,12 +11,9 @@ class Command(BaseCommand): def add_arguments(self, parser): # Positional argument - parser.add_argument( - "admin_email", type=str, help="Instance Admin Email" - ) + parser.add_argument("admin_email", type=str, help="Instance Admin Email") def handle(self, *args, **options): - admin_email = options.get("admin_email", False) if not admin_email: @@ -36,13 +33,9 @@ class Command(BaseCommand): ) if not created: - raise CommandError( - "The provided email is already an instance admin." - ) + raise CommandError("The provided email is already an instance admin.") - self.stdout.write( - self.style.SUCCESS("Successfully created the admin") - ) + self.stdout.write(self.style.SUCCESS("Successfully created the admin")) except Exception as e: print(e) raise CommandError("Failed to create the instance admin.") diff --git a/apiserver/plane/db/management/commands/create_project_member.py b/apiserver/plane/db/management/commands/create_project_member.py new file mode 100644 index 000000000..a2a5c669e --- /dev/null +++ b/apiserver/plane/db/management/commands/create_project_member.py @@ -0,0 +1,112 @@ +# Django imports +from typing import Any +from django.core.management import BaseCommand, CommandError + +# Module imports +from plane.db.models import ( + User, + WorkspaceMember, + ProjectMember, + Project, + IssueUserProperty, +) + + +class Command(BaseCommand): + + help = "Add a member to a project. If present in the workspace" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument( + "--project_id", + type=str, + nargs="?", + help="Project ID", + ) + parser.add_argument( + "--user_email", + type=str, + nargs="?", + help="User Email", + ) + parser.add_argument( + "--role", + type=int, + nargs="?", + help="Role of the user in the project", + ) + + def handle(self, *args: Any, **options: Any): + try: + if not options["project_id"]: + raise CommandError("Project ID is required") + if not options["user_email"]: + raise CommandError("User Email is required") + + project_id = options["project_id"] + user_email = options["user_email"] + role = options.get("role", 20) + + print(f"Role: {role}") + + user = User.objects.filter(email=user_email).first() + if not user: + raise CommandError("User not found") + + # Check if the project exists + project = Project.objects.filter(pk=project_id).first() + if not project: + raise CommandError("Project not found") + + # Check if the user exists in the workspace + if not WorkspaceMember.objects.filter( + workspace=project.workspace, member=user, is_active=True + ).exists(): + raise CommandError("User not member in workspace") + + # Get the smallest sort order + smallest_sort_order = ( + ProjectMember.objects.filter( + workspace_id=project.workspace_id, + ) + .order_by("sort_order") + .first() + ) + + if smallest_sort_order: + sort_order = smallest_sort_order.sort_order - 1000 + else: + sort_order = 65535 + + if ProjectMember.objects.filter( + project=project, + member=user, + ).exists(): + # Update the project member + ProjectMember.objects.filter( + project=project, + member=user, + ).update(is_active=True, sort_order=sort_order, role=role) + else: + # Create the project member + ProjectMember.objects.create( + project=project, + member=user, + role=role, + sort_order=sort_order, + ) + + # Issue Property + IssueUserProperty.objects.get_or_create(user=user, project=project) + + # Success message + self.stdout.write( + self.style.SUCCESS( + f"User {user_email} added to project {project_id}" + ) + ) + return + except CommandError as e: + self.stdout.write(self.style.ERROR(e)) + return diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py index 9c137d320..8ec472bfa 100644 --- a/apiserver/plane/db/management/commands/reset_password.py +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -52,15 +52,11 @@ class Command(BaseCommand): results = zxcvbn(password) if results["score"] < 3: - raise CommandError( - "Password is too common please set a complex password" - ) + raise CommandError("Password is too common please set a complex password") # Set user password user.set_password(password) user.is_password_autoset = False user.save() - self.stdout.write( - self.style.SUCCESS("User password updated succesfully") - ) + self.stdout.write(self.style.SUCCESS("User password updated succesfully")) diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py index facea7e9c..2ed20eeb3 100644 --- a/apiserver/plane/db/management/commands/test_email.py +++ b/apiserver/plane/db/management/commands/test_email.py @@ -53,9 +53,7 @@ class Command(BaseCommand): subject=subject, body=text_content, from_email=EMAIL_FROM, - to=[ - receiver_email, - ], + to=[receiver_email], connection=connection, ) msg.attach_alternative(html_content, "text/html") @@ -63,7 +61,5 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: self.stdout.write( - self.style.ERROR( - f"Error: Email could not be delivered due to {e}" - ) + self.style.ERROR(f"Error: Email could not be delivered due to {e}") ) diff --git a/apiserver/plane/db/management/commands/update_bucket.py b/apiserver/plane/db/management/commands/update_bucket.py new file mode 100644 index 000000000..27eb5c83e --- /dev/null +++ b/apiserver/plane/db/management/commands/update_bucket.py @@ -0,0 +1,205 @@ +# Python imports +import os +import boto3 +from botocore.exceptions import ClientError +import json + +# Django imports +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def get_s3_client(self): + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get("AWS_S3_ENDPOINT_URL"), # MinIO endpoint + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), + ) + return s3_client + + # Check if the access key has the required permissions + def check_s3_permissions(self, bucket_name): + s3_client = self.get_s3_client() + permissions = { + "s3:GetObject": False, + "s3:ListBucket": False, + "s3:PutBucketPolicy": False, + "s3:PutObject": False, + } + + # 1. Test s3:ListBucket (attempt to list the bucket contents) + try: + s3_client.list_objects_v2(Bucket=bucket_name) + permissions["s3:ListBucket"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("ListBucket permission denied.") + else: + self.stdout.write(f"Error in ListBucket: {e}") + + # 2. Test s3:GetObject (attempt to get a specific object) + try: + response = s3_client.list_objects_v2(Bucket=bucket_name) + if "Contents" in response: + test_object_key = response["Contents"][0]["Key"] + s3_client.get_object(Bucket=bucket_name, Key=test_object_key) + permissions["s3:GetObject"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("GetObject permission denied.") + else: + self.stdout.write(f"Error in GetObject: {e}") + + # 3. Test s3:PutObject (attempt to upload an object) + try: + s3_client.put_object( + Bucket=bucket_name, Key="test_permission_check.txt", Body=b"Test" + ) + permissions["s3:PutObject"] = True + # Clean up + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutObject permission denied.") + else: + self.stdout.write(f"Error in PutObject: {e}") + + # Clean up + try: + s3_client.delete_object(Bucket=bucket_name, Key="test_permission_check.txt") + except ClientError: + self.stdout.write("Coudn't delete test object") + + # 4. Test s3:PutBucketPolicy (attempt to put a bucket policy) + try: + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": f"arn:aws:s3:::{bucket_name}/*", + } + ], + } + s3_client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy)) + permissions["s3:PutBucketPolicy"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutBucketPolicy permission denied.") + else: + self.stdout.write(f"Error in PutBucketPolicy: {e}") + + return permissions + + def generate_bucket_policy(self, bucket_name): + s3_client = self.get_s3_client() + response = s3_client.list_objects_v2(Bucket=bucket_name) + public_object_resource = [] + if "Contents" in response: + for obj in response["Contents"]: + object_key = obj["Key"] + public_object_resource.append( + f"arn:aws:s3:::{bucket_name}/{object_key}" + ) + bucket_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": public_object_resource, + } + ], + } + return bucket_policy + + def make_objects_public(self, bucket_name): + # Initialize S3 client + s3_client = self.get_s3_client() + # Get the bucket policy + bucket_policy = self.generate_bucket_policy(bucket_name) + # Apply the policy to the bucket + s3_client.put_bucket_policy( + Bucket=bucket_name, Policy=json.dumps(bucket_policy) + ) + # Print a success message + self.stdout.write("Bucket is private, but existing objects remain public.") + return + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + + # Check if the bucket exists + s3_client = self.get_s3_client() + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + + if not bucket_name: + self.stdout.write( + self.style.ERROR( + "Please set the AWS_S3_BUCKET_NAME environment variable." + ) + ) + return + + self.stdout.write(self.style.NOTICE("Checking bucket...")) + # Check if the bucket exists + try: + s3_client.head_bucket(Bucket=bucket_name) + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "404": + self.stdout.write( + self.style.ERROR(f"Bucket '{bucket_name}' does not exist.") + ) + return + else: + self.stdout.write(f"Error: {e}") + # If the bucket exists, print a success message + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' exists.")) + + try: + # Check the permissions of the access key + permissions = self.check_s3_permissions(bucket_name) + except ClientError as e: + self.stdout.write(f"Error: {e}") + except Exception as e: + self.stdout.write(f"Error: {e}") + # If the access key has the required permissions + try: + if all(permissions.values()): + self.stdout.write( + self.style.SUCCESS("Access key has the required permissions.") + ) + # Making the existing objects public + self.make_objects_public(bucket_name) + return + except Exception as e: + self.stdout.write(f"Error: {e}") + + # write the bucket policy to a file + self.stdout.write( + self.style.WARNING( + "Generating permissions.json for manual bucket policy update." + ) + ) + try: + # Writing to a file + with open("permissions.json", "w") as f: + f.write(json.dumps(self.generate_bucket_policy(bucket_name))) + self.stdout.write( + self.style.WARNING("Permissions have been written to permissions.json.") + ) + return + except IOError as e: + self.stdout.write(f"Error writing permissions.json: {e}") + return diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index 81d91bb78..edca91f2c 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -3,7 +3,6 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -from plane.db.models import IssueRelation from sentry_sdk import capture_exception import uuid @@ -11,6 +10,7 @@ import uuid def create_issue_relation(apps, schema_editor): try: IssueBlockerModel = apps.get_model("db", "IssueBlocker") + IssueRelation = apps.get_model("db", "IssueRelation") updated_issue_relation = [] for blocked_issue in IssueBlockerModel.objects.all(): updated_issue_relation.append( diff --git a/apiserver/plane/db/migrations/0053_auto_20240102_1315.py b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py index 32b5ad2d5..666e51482 100644 --- a/apiserver/plane/db/migrations/0053_auto_20240102_1315.py +++ b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py @@ -1,11 +1,10 @@ # Generated by Django 4.2.7 on 2024-01-02 13:15 - -from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView from django.db import migrations def workspace_user_properties(apps, schema_editor): WorkspaceMember = apps.get_model("db", "WorkspaceMember") + WorkspaceUserProperties = apps.get_model("db", "WorkspaceUserProperties") updated_workspace_user_properties = [] for workspace_members in WorkspaceMember.objects.all(): updated_workspace_user_properties.append( @@ -21,12 +20,14 @@ def workspace_user_properties(apps, schema_editor): ) ) WorkspaceUserProperties.objects.bulk_create( - updated_workspace_user_properties, batch_size=2000 + updated_workspace_user_properties, + batch_size=2000, ) def project_user_properties(apps, schema_editor): IssueProperty = apps.get_model("db", "IssueProperty") + ProjectMember = apps.get_model("db", "ProjectMember") updated_issue_user_properties = [] for issue_property in IssueProperty.objects.all(): project_member = ProjectMember.objects.filter( @@ -49,6 +50,7 @@ def project_user_properties(apps, schema_editor): def issue_view(apps, schema_editor): GlobalView = apps.get_model("db", "GlobalView") + IssueView = apps.get_model("db", "IssueView") updated_issue_views = [] for global_view in GlobalView.objects.all(): diff --git a/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py new file mode 100644 index 000000000..ee70f6615 --- /dev/null +++ b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py @@ -0,0 +1,2036 @@ +# Generated by Django 4.2.15 on 2024-09-24 08:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid +from django.db.models import Prefetch + + +def migrate_draft_issues(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + DraftIssue = apps.get_model("db", "DraftIssue") + IssueAssignee = apps.get_model("db", "IssueAssignee") + DraftIssueAssignee = apps.get_model("db", "DraftIssueAssignee") + IssueLabel = apps.get_model("db", "IssueLabel") + DraftIssueLabel = apps.get_model("db", "DraftIssueLabel") + ModuleIssue = apps.get_model("db", "ModuleIssue") + DraftIssueModule = apps.get_model("db", "DraftIssueModule") + DraftIssueCycle = apps.get_model("db", "DraftIssueCycle") + + # Fetch all draft issues with their related assignees and labels + issues = ( + Issue.objects.filter(is_draft=True) + .select_related("issue_cycle__cycle") + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.select_related("assignee"), + ), + Prefetch( + "label_issue", + queryset=IssueLabel.objects.select_related("label"), + ), + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module"), + ), + ) + ) + + draft_issues = [] + draft_issue_cycle = [] + draft_issue_labels = [] + draft_issue_modules = [] + draft_issue_assignees = [] + # issue_ids_to_delete = [] + + for issue in issues: + draft_issue = DraftIssue( + parent_id=issue.parent_id, + state_id=issue.state_id, + estimate_point_id=issue.estimate_point_id, + name=issue.name, + description=issue.description, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_binary=issue.description_binary, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + ) + draft_issues.append(draft_issue) + + for assignee in issue.issue_assignee.all(): + draft_issue_assignees.append( + DraftIssueAssignee( + draft_issue=draft_issue, + assignee=assignee.assignee, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # Prepare labels for bulk insert + for label in issue.label_issue.all(): + draft_issue_labels.append( + DraftIssueLabel( + draft_issue=draft_issue, + label=label.label, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + for module_issue in issue.issue_module.all(): + draft_issue_modules.append( + DraftIssueModule( + draft_issue=draft_issue, + module=module_issue.module, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + if hasattr(issue, "issue_cycle") and issue.issue_cycle: + draft_issue_cycle.append( + DraftIssueCycle( + draft_issue=draft_issue, + cycle=issue.issue_cycle.cycle, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # issue_ids_to_delete.append(issue.id) + + # Bulk create draft issues + DraftIssue.objects.bulk_create(draft_issues) + + # Bulk create draft assignees and labels + DraftIssueLabel.objects.bulk_create(draft_issue_labels) + DraftIssueAssignee.objects.bulk_create(draft_issue_assignees) + + # Bulk create draft modules + DraftIssueCycle.objects.bulk_create(draft_issue_cycle) + DraftIssueModule.objects.bulk_create(draft_issue_modules) + + # Delete original issues + # Issue.objects.filter(id__in=issue_ids_to_delete).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0076_alter_projectmember_role_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DraftIssue", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Issue Name", + ), + ), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

"), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ("description_binary", models.BinaryField(null=True)), + ( + "priority", + models.CharField( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ("sort_order", models.FloatField(default=65535)), + ("completed_at", models.DateTimeField(null=True)), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ], + options={ + "verbose_name": "DraftIssue", + "verbose_name_plural": "DraftIssues", + "db_table": "draft_issues", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="cycle", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AddField( + model_name="project", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + model_name="cycle", + name="end_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="End Date" + ), + ), + migrations.AlterField( + model_name="cycle", + name="start_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Start Date" + ), + ), + migrations.CreateModel( + name="DraftIssueModule", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted 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", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.draftissue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.module", + ), + ), + ( + "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": "Draft Issue Module", + "verbose_name_plural": "Draft Issue Modules", + "db_table": "draft_issue_modules", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueLabel", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted 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", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.draftissue", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.label", + ), + ), + ( + "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": "Draft Issue Label", + "verbose_name_plural": "Draft Issue Labels", + "db_table": "draft_issue_labels", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueCycle", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted 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", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.cycle", + ), + ), + ( + "draft_issue", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.draftissue", + ), + ), + ( + "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": "Draft Issue Cycle", + "verbose_name_plural": "Draft Issue Cycles", + "db_table": "draft_issue_cycles", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueAssignee", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "assignee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "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", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to="db.draftissue", + ), + ), + ( + "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": "Draft Issue Assignee", + "verbose_name_plural": "Draft Issue Assignees", + "db_table": "draft_issue_assignees", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="draftissue", + name="assignees", + field=models.ManyToManyField( + blank=True, + related_name="draft_assignee", + through="db.DraftIssueAssignee", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="draftissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="estimate_point", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_estimates", + to="db.estimatepoint", + ), + ), + migrations.AddField( + model_name="draftissue", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="draft_labels", + through="db.DraftIssueLabel", + to="db.label", + ), + ), + migrations.AddField( + model_name="draftissue", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_parent_issue", + to="db.issue", + ), + ), + migrations.AddField( + model_name="draftissue", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AddField( + model_name="draftissue", + name="state", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="state_draft_issue", + to="db.state", + ), + ), + migrations.AddField( + model_name="draftissue", + name="type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_type", + to="db.issuetype", + ), + ), + migrations.AddField( + model_name="draftissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AddConstraint( + model_name="draftissuemodule", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "module"), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissuemodule", + unique_together={("draft_issue", "module", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="draftissueassignee", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "assignee"), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissueassignee", + unique_together={("draft_issue", "assignee", "deleted_at")}, + ), + migrations.AddField( + model_name="cycle", + name="version", + field=models.IntegerField(default=1), + ), + migrations.RunPython(migrate_draft_issues), + ] diff --git a/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py new file mode 100644 index 000000000..3839f4e73 --- /dev/null +++ b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py @@ -0,0 +1,179 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.asset + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "db", + "0077_draftissue_cycle_user_timezone_project_user_timezone_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="comment", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issuecomment", + ), + ), + migrations.AddField( + model_name="fileasset", + name="entity_type", + field=models.CharField( + blank=True, + choices=[ + ("ISSUE_ATTACHMENT", "Issue Attachment"), + ("ISSUE_DESCRIPTION", "Issue Description"), + ("COMMENT_DESCRIPTION", "Comment Description"), + ("PAGE_DESCRIPTION", "Page Description"), + ("USER_COVER", "User Cover"), + ("USER_AVATAR", "User Avatar"), + ("WORKSPACE_LOGO", "Workspace Logo"), + ("PROJECT_COVER", "Project Cover"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="fileasset", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="is_uploaded", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="fileasset", + name="issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issue", + ), + ), + migrations.AddField( + model_name="fileasset", + name="page", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.page", + ), + ), + migrations.AddField( + model_name="fileasset", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.project", + ), + ), + migrations.AddField( + model_name="fileasset", + name="size", + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name="fileasset", + name="storage_metadata", + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="project", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="avatar_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_avatar", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="workspace", + name="logo_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_logo", + to="db.fileasset", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="asset", + field=models.FileField( + max_length=800, upload_to=plane.db.models.asset.get_upload_path + ), + ), + migrations.AlterField( + model_name="integration", + name="avatar_url", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="project", + name="cover_image", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="workspace", + name="logo", + field=models.TextField(blank=True, null=True, verbose_name="Logo"), + ), + ] diff --git a/apiserver/plane/db/migrations/0079_auto_20241009_0619.py b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py new file mode 100644 index 000000000..e3fc904a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.db import migrations + + +def move_attachment_to_fileasset(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + IssueAttachment = apps.get_model("db", "IssueAttachment") + + bulk_issue_attachment = [] + for issue_attachment in IssueAttachment.objects.values( + "issue_id", + "project_id", + "workspace_id", + "asset", + "attributes", + "external_source", + "external_id", + "deleted_at", + "created_by_id", + "updated_by_id", + ): + bulk_issue_attachment.append( + FileAsset( + issue_id=issue_attachment["issue_id"], + entity_type="ISSUE_ATTACHMENT", + project_id=issue_attachment["project_id"], + workspace_id=issue_attachment["workspace_id"], + attributes=issue_attachment["attributes"], + asset=issue_attachment["asset"], + external_source=issue_attachment["external_source"], + external_id=issue_attachment["external_id"], + deleted_at=issue_attachment["deleted_at"], + created_by_id=issue_attachment["created_by_id"], + updated_by_id=issue_attachment["updated_by_id"], + size=issue_attachment["attributes"].get("size", 0), + ) + ) + + FileAsset.objects.bulk_create(bulk_issue_attachment, batch_size=1000) + + +def mark_existing_file_uploads(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + # Mark all existing file uploads as uploaded + FileAsset.objects.update(is_uploaded=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0078_fileasset_comment_fileasset_entity_type_and_more"), + ] + + operations = [ + migrations.RunPython( + move_attachment_to_fileasset, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + mark_existing_file_uploads, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py b/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py new file mode 100644 index 000000000..f51130193 --- /dev/null +++ b/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.15 on 2024-10-12 18:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0079_auto_20241009_0619"), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="draft_issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.draftissue", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="entity_type", + field=models.CharField( + blank=True, + choices=[ + ("ISSUE_ATTACHMENT", "Issue Attachment"), + ("ISSUE_DESCRIPTION", "Issue Description"), + ("COMMENT_DESCRIPTION", "Comment Description"), + ("PAGE_DESCRIPTION", "Page Description"), + ("USER_COVER", "User Cover"), + ("USER_AVATAR", "User Avatar"), + ("WORKSPACE_LOGO", "Workspace Logo"), + ("PROJECT_COVER", "Project Cover"), + ("DRAFT_ISSUE_ATTACHMENT", "Draft Issue Attachment"), + ("DRAFT_ISSUE_DESCRIPTION", "Draft Issue Description"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py b/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py new file mode 100644 index 000000000..984f25444 --- /dev/null +++ b/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py @@ -0,0 +1,187 @@ +# Generated by Django 4.2.16 on 2024-10-15 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0080_fileasset_draft_issue_alter_fileasset_entity_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="globalview", + name="created_by", + ), + migrations.RemoveField( + model_name="globalview", + name="updated_by", + ), + migrations.RemoveField( + model_name="globalview", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="issueviewfavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="project", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="user", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="view", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="modulefavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="modulefavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="module", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="project", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="user", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="workspace", + ), + migrations.RemoveField( + model_name="pageblock", + name="created_by", + ), + migrations.RemoveField( + model_name="pageblock", + name="issue", + ), + migrations.RemoveField( + model_name="pageblock", + name="page", + ), + migrations.RemoveField( + model_name="pageblock", + name="project", + ), + migrations.RemoveField( + model_name="pageblock", + name="updated_by", + ), + migrations.RemoveField( + model_name="pageblock", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="pagefavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="pagefavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="page", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="project", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="user", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="projectfavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="projectfavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="project", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="user", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="workspace", + ), + migrations.AddField( + model_name="issuetype", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuetype", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.DeleteModel( + name="CycleFavorite", + ), + migrations.DeleteModel( + name="GlobalView", + ), + migrations.DeleteModel( + name="IssueViewFavorite", + ), + migrations.DeleteModel( + name="ModuleFavorite", + ), + migrations.DeleteModel( + name="PageBlock", + ), + migrations.DeleteModel( + name="PageFavorite", + ), + migrations.DeleteModel( + name="ProjectFavorite", + ), + ] diff --git a/apiserver/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py b/apiserver/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py new file mode 100644 index 000000000..9d0279eb4 --- /dev/null +++ b/apiserver/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.15 on 2024-10-22 08:00 + +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0081_remove_globalview_created_by_and_more"), + ] + + operations = [ + migrations.AlterModelManagers( + name="issue", + managers=[ + ("issue_objects", django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name="cycleissue", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), + ), + migrations.AlterField( + model_name="draftissuecycle", + name="draft_issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.draftissue", + ), + ), + migrations.AlterUniqueTogether( + name="cycleissue", + unique_together={("issue", "cycle", "deleted_at")}, + ), + migrations.AlterUniqueTogether( + name="draftissuecycle", + unique_together={("draft_issue", "cycle", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="cycleissue", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("cycle", "issue"), + name="cycle_issue_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="draftissuecycle", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "cycle"), + name="draft_issue_cycle_when_deleted_at_null", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0083_device_workspace_timezone_and_more.py b/apiserver/plane/db/migrations/0083_device_workspace_timezone_and_more.py new file mode 100644 index 000000000..587ee8800 --- /dev/null +++ b/apiserver/plane/db/migrations/0083_device_workspace_timezone_and_more.py @@ -0,0 +1,874 @@ +# Generated by Django 4.2.15 on 2024-11-01 17:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0082_alter_issue_managers_alter_cycleissue_issue_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Device", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "device_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "device_type", + models.CharField( + choices=[ + ("ANDROID", "Android"), + ("IOS", "iOS"), + ("WEB", "Web"), + ("DESKTOP", "Desktop"), + ], + max_length=255, + ), + ), + ( + "push_token", + models.CharField(blank=True, max_length=255, null=True), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="devices", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Device", + "verbose_name_plural": "Devices", + "db_table": "devices", + }, + ), + migrations.AddField( + model_name="issuetype", + name="is_epic", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="workspace", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + model_name="issuerelation", + name="relation_type", + field=models.CharField( + choices=[ + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ("start_before", "Start Before"), + ("finish_before", "Finish Before"), + ], + default="blocked_by", + max_length=20, + verbose_name="Issue Relation Type", + ), + ), + migrations.AlterField( + model_name="issuetype", + name="level", + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name="label", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.CreateModel( + name="DeviceSession", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "user_agent", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "ip_address", + models.GenericIPAddressField(blank=True, null=True), + ), + ("start_time", models.DateTimeField(auto_now_add=True)), + ("end_time", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="db.device", + ), + ), + ( + "session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="device_sessions", + to="db.session", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Device Session", + "verbose_name_plural": "Device Sessions", + "db_table": "device_sessions", + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py b/apiserver/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py new file mode 100644 index 000000000..25bfcb8fb --- /dev/null +++ b/apiserver/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.15 on 2024-11-05 07:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0083_device_workspace_timezone_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="label", + name="label_unique_name_project_when_deleted_at_null", + ), + migrations.AlterUniqueTogether( + name="label", + unique_together=set(), + ), + migrations.AddField( + model_name="deployboard", + name="is_disabled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="inboxissue", + name="extra", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="inboxissue", + name="source_email", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="bot_type", + field=models.CharField( + blank=True, max_length=30, null=True, verbose_name="Bot Type" + ), + ), + migrations.AlterField( + model_name="deployboard", + name="entity_name", + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name="inboxissue", + name="source", + field=models.CharField( + blank=True, default="IN_APP", max_length=255, null=True + ), + ), + migrations.AddConstraint( + model_name="label", + constraint=models.UniqueConstraint( + condition=models.Q( + ("deleted_at__isnull", True), ("project__isnull", True) + ), + fields=("name",), + name="unique_name_when_project_null_and_not_deleted", + ), + ), + migrations.AddConstraint( + model_name="label", + constraint=models.UniqueConstraint( + condition=models.Q( + ("deleted_at__isnull", True), ("project__isnull", False) + ), + fields=("project", "name"), + name="unique_project_name_when_not_deleted", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py b/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py new file mode 100644 index 000000000..16c4167cf --- /dev/null +++ b/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py @@ -0,0 +1,139 @@ +# Generated by Django 4.2.15 on 2024-11-06 08:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name="Inbox", + new_name="Intake", + ), + migrations.AlterModelTable( + name="Intake", + table="intakes", + ), + migrations.AlterModelOptions( + name="Intake", + options={ + "verbose_name": "Intake", + "verbose_name_plural": "Intakes", + "ordering": ("name",), + }, + ), + migrations.AlterField( + model_name="Intake", + name="description", + field=models.TextField( + blank=True, verbose_name="Intake Description" + ), + ), + migrations.RenameModel( + old_name="InboxIssue", + new_name="IntakeIssue", + ), + # Rename the 'inbox' field to 'intake' + migrations.RenameField( + model_name="IntakeIssue", + old_name="inbox", + new_name="intake", + ), + # Update ForeignKey related_name for 'intake' + migrations.AlterField( + model_name="IntakeIssue", + name="intake", + field=models.ForeignKey( + "db.Intake", + related_name="issue_intake", + on_delete=django.db.models.deletion.CASCADE, + ), + ), + # Update ForeignKey related_name for 'issue' + migrations.AlterField( + model_name="IntakeIssue", + name="issue", + field=models.ForeignKey( + "db.Issue", + related_name="issue_intake", + on_delete=django.db.models.deletion.CASCADE, + ), + ), + # Update ForeignKey related_name for 'duplicate_to' + migrations.AlterField( + model_name="IntakeIssue", + name="duplicate_to", + field=models.ForeignKey( + "db.Issue", + related_name="intake_duplicate", + on_delete=django.db.models.deletion.SET_NULL, + null=True, + ), + ), + # Update Meta options + migrations.AlterModelOptions( + name="IntakeIssue", + options={ + "verbose_name": "IntakeIssue", + "verbose_name_plural": "IntakeIssues", + "ordering": ("-created_at",), + }, + ), + # Update db_table + migrations.AlterModelTable( + name="IntakeIssue", + table="intake_issues", + ), + migrations.RenameField( + model_name="project", + old_name="inbox_view", + new_name="intake_view", + ), + migrations.RenameField( + model_name="deployboard", + old_name="inbox", + new_name="intake", + ), + migrations.RenameField( + model_name="projectdeployboard", + old_name="inbox", + new_name="intake", + ), + migrations.RemoveConstraint( + model_name="intake", + name="inbox_unique_name_project_when_deleted_at_null", + ), + migrations.AlterField( + model_name="deployboard", + name="intake", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="publish_intake", + to="db.intake", + ), + ), + migrations.AlterField( + model_name="projectdeployboard", + name="intake", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="board_intake", + to="db.intake", + ), + ), + migrations.AddConstraint( + model_name="intake", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name", "project"), + name="intake_unique_name_project_when_deleted_at_null", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py new file mode 100644 index 000000000..d38f17c5d --- /dev/null +++ b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py @@ -0,0 +1,242 @@ +# Generated by Django 4.2.15 on 2024-11-27 09:07 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import plane.db.models.webhook +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="IssueVersion", + 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" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("parent", models.UUIDField(blank=True, null=True)), + ("state", models.UUIDField(blank=True, null=True)), + ("estimate_point", models.UUIDField(blank=True, null=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="

")), + ("description_stripped", models.TextField(blank=True, null=True)), + ("description_binary", models.BinaryField(null=True)), + ( + "priority", + models.CharField( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ( + "sequence_id", + models.IntegerField(default=1, verbose_name="Issue Sequence ID"), + ), + ("sort_order", models.FloatField(default=65535)), + ("completed_at", models.DateTimeField(null=True)), + ("archived_at", models.DateField(null=True)), + ("is_draft", models.BooleanField(default=False)), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("type", models.UUIDField(blank=True, null=True)), + ( + "last_saved_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("owned_by", models.UUIDField()), + ( + "assignees", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ( + "labels", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ("cycle", models.UUIDField(blank=True, null=True)), + ( + "modules", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ("properties", models.JSONField(default=dict)), + ("meta", 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", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Issue Version", + "verbose_name_plural": "Issue Versions", + "db_table": "issue_versions", + "ordering": ("-created_at",), + }, + ), + migrations.AlterUniqueTogether( + name="teampage", + unique_together=None, + ), + migrations.RemoveField( + model_name="teampage", + name="created_by", + ), + migrations.RemoveField( + model_name="teampage", + name="page", + ), + migrations.RemoveField( + model_name="teampage", + name="team", + ), + migrations.RemoveField( + model_name="teampage", + name="updated_by", + ), + migrations.RemoveField( + model_name="teampage", + name="workspace", + ), + migrations.RemoveField( + model_name="page", + name="teams", + ), + migrations.RemoveField( + model_name="team", + name="members", + ), + migrations.AddField( + model_name="fileasset", + name="entity_identifier", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="webhook", + name="is_internal", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="fileasset", + name="entity_type", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="webhook", + name="url", + field=models.URLField( + max_length=1024, + validators=[ + plane.db.models.webhook.validate_schema, + plane.db.models.webhook.validate_domain, + ], + ), + ), + migrations.DeleteModel( + name="TeamMember", + ), + migrations.DeleteModel( + name="TeamPage", + ), + ] diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index 0203eb8ce..b198de121 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -9,13 +9,8 @@ from plane.bgtasks.deletion_task import soft_delete_related_objects class TimeAuditModel(models.Model): """To path when the record was created and last modified""" - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name="Created At", - ) - updated_at = models.DateTimeField( - auto_now=True, verbose_name="Last Modified At" - ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") class Meta: abstract = True @@ -43,19 +38,25 @@ class UserAuditModel(models.Model): abstract = True +class SoftDeletionQuerySet(models.QuerySet): + def delete(self, soft=True): + if soft: + return self.update(deleted_at=timezone.now()) + else: + return super().delete() + + class SoftDeletionManager(models.Manager): def get_queryset(self): - return super().get_queryset().filter(deleted_at__isnull=True) + return SoftDeletionQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) class SoftDeleteModel(models.Model): """To soft delete records""" - deleted_at = models.DateTimeField( - verbose_name="Deleted At", - null=True, - blank=True, - ) + deleted_at = models.DateTimeField(verbose_name="Deleted At", null=True, blank=True) objects = SoftDeletionManager() all_objects = models.Manager() @@ -70,10 +71,7 @@ class SoftDeleteModel(models.Model): self.save(using=using) soft_delete_related_objects.delay( - self._meta.app_label, - self._meta.model_name, - self.pk, - using=using, + self._meta.app_label, self._meta.model_name, self.pk, using=using ) else: diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index e7def641d..36810956c 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -2,13 +2,20 @@ from .analytic import AnalyticView from .api import APIActivityLog, APIToken from .asset import FileAsset from .base import BaseModel -from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties +from .cycle import Cycle, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget from .deploy_board import DeployBoard +from .draft import ( + DraftIssue, + DraftIssueAssignee, + DraftIssueLabel, + DraftIssueModule, + DraftIssueCycle, +) from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer -from .inbox import Inbox, InboxIssue +from .intake import Intake, IntakeIssue from .integration import ( GithubCommentSync, GithubIssueSync, @@ -23,7 +30,6 @@ from .issue import ( Issue, IssueActivity, IssueAssignee, - IssueAttachment, IssueBlocker, IssueComment, IssueLabel, @@ -35,33 +41,13 @@ from .issue import ( IssueSequence, IssueSubscriber, IssueVote, - Label, -) -from .module import ( - Module, - ModuleFavorite, - ModuleIssue, - ModuleLink, - ModuleMember, - ModuleUserProperties, -) -from .notification import ( - EmailNotificationLog, - Notification, - UserNotificationPreference, -) -from .page import ( - Page, - PageFavorite, - PageLabel, - PageLog, - ProjectPage, - PageVersion, ) +from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties +from .notification import EmailNotificationLog, Notification, UserNotificationPreference +from .page import Page, PageLabel, PageLog, ProjectPage, PageVersion from .project import ( Project, ProjectBaseModel, - ProjectFavorite, ProjectIdentifier, ProjectMember, ProjectMemberInvite, @@ -72,11 +58,9 @@ from .session import Session from .social_connection import SocialLoginConnection from .state import State from .user import Account, Profile, User -from .view import IssueView, IssueViewFavorite +from .view import IssueView from .webhook import Webhook, WebhookLog from .workspace import ( - Team, - TeamMember, Workspace, WorkspaceBaseModel, WorkspaceMember, @@ -87,19 +71,15 @@ from .workspace import ( from .importer import Importer -from .page import Page, PageLog, PageFavorite, PageLabel +from .page import Page, PageLog, PageLabel from .estimate import Estimate, EstimatePoint -from .inbox import Inbox, InboxIssue +from .intake import Intake, IntakeIssue from .analytic import AnalyticView -from .notification import ( - Notification, - UserNotificationPreference, - EmailNotificationLog, -) +from .notification import Notification, UserNotificationPreference, EmailNotificationLog from .exporter import ExporterHistory @@ -112,3 +92,7 @@ from .favorite import UserFavorite from .issue_type import IssueType from .recent_visit import UserRecentVisit + +from .label import Label + +from .device import Device, DeviceSession diff --git a/apiserver/plane/db/models/api.py b/apiserver/plane/db/models/api.py index bc24ee8a8..01be8e643 100644 --- a/apiserver/plane/db/models/api.py +++ b/apiserver/plane/db/models/api.py @@ -30,18 +30,13 @@ class APIToken(BaseModel): # User Information user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="bot_tokens", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bot_tokens" ) user_type = models.PositiveSmallIntegerField( choices=((0, "Human"), (1, "Bot")), default=0 ) workspace = models.ForeignKey( - "db.Workspace", - related_name="api_tokens", - on_delete=models.CASCADE, - null=True, + "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True ) expired_at = models.DateTimeField(blank=True, null=True) is_service = models.BooleanField(default=False) diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index a11ba89a4..9f99a8144 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -5,14 +5,12 @@ from uuid import uuid4 from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.core.validators import FileExtensionValidator # Module import 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}" @@ -28,13 +26,22 @@ class FileAsset(BaseModel): A file asset. """ + class EntityTypeContext(models.TextChoices): + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT" + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION" + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION" + PAGE_DESCRIPTION = "PAGE_DESCRIPTION" + USER_COVER = "USER_COVER" + USER_AVATAR = "USER_AVATAR" + WORKSPACE_LOGO = "WORKSPACE_LOGO" + PROJECT_COVER = "PROJECT_COVER" + DRAFT_ISSUE_ATTACHMENT = "DRAFT_ISSUE_ATTACHMENT" + DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION" + attributes = models.JSONField(default=dict) - asset = models.FileField( - upload_to=get_upload_path, - validators=[ - FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]), - file_size, - ], + asset = models.FileField(upload_to=get_upload_path, max_length=800) + user = models.ForeignKey( + "db.User", on_delete=models.CASCADE, null=True, related_name="assets" ) workspace = models.ForeignKey( "db.Workspace", @@ -42,8 +49,47 @@ class FileAsset(BaseModel): null=True, related_name="assets", ) + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, null=True, related_name="assets" + ) + comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, null=True, related_name="assets" + ) + entity_type = models.CharField( + max_length=255, + null=True, + blank=True, + ) + entity_identifier = models.CharField( + max_length=255, + null=True, + blank=True, + ) is_deleted = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) + external_id = models.CharField(max_length=255, null=True, blank=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + size = models.FloatField(default=0) + is_uploaded = models.BooleanField(default=False) + storage_metadata = models.JSONField(default=dict, null=True, blank=True) class Meta: verbose_name = "File Asset" @@ -53,3 +99,26 @@ class FileAsset(BaseModel): def __str__(self): return str(self.asset) + + @property + def asset_url(self): + if ( + self.entity_type == self.EntityTypeContext.WORKSPACE_LOGO + or self.entity_type == self.EntityTypeContext.USER_AVATAR + or self.entity_type == self.EntityTypeContext.USER_COVER + or self.entity_type == self.EntityTypeContext.PROJECT_COVER + ): + return f"/api/assets/v2/static/{self.id}/" + + if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" + + if self.entity_type in [ + self.EntityTypeContext.ISSUE_DESCRIPTION, + self.EntityTypeContext.COMMENT_DESCRIPTION, + self.EntityTypeContext.PAGE_DESCRIPTION, + self.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION, + ]: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/" + + return None diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py index 63c08afa4..d0531e881 100644 --- a/apiserver/plane/db/models/base.py +++ b/apiserver/plane/db/models/base.py @@ -12,11 +12,7 @@ from ..mixins import AuditModel class BaseModel(AuditModel): id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) class Meta: diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index b3ce49e01..6449fd145 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -1,3 +1,6 @@ +# Python imports +import pytz + # Django imports from django.conf import settings from django.db import models @@ -52,13 +55,9 @@ def get_default_display_properties(): class Cycle(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Cycle Name") - description = models.TextField( - verbose_name="Cycle Description", blank=True - ) - start_date = models.DateField( - verbose_name="Start Date", blank=True, null=True - ) - end_date = models.DateField(verbose_name="End Date", blank=True, null=True) + description = models.TextField(verbose_name="Cycle Description", blank=True) + start_date = models.DateTimeField(verbose_name="Start Date", blank=True, null=True) + end_date = models.DateTimeField(verbose_name="End Date", blank=True, null=True) owned_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -71,6 +70,10 @@ class Cycle(ProjectBaseModel): progress_snapshot = models.JSONField(default=dict) archived_at = models.DateTimeField(null=True) logo_props = models.JSONField(default=dict) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + version = models.IntegerField(default=1) class Meta: verbose_name = "Cycle" @@ -80,9 +83,9 @@ class Cycle(ProjectBaseModel): def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Cycle.objects.filter( - project=self.project - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + smallest_sort_order = Cycle.objects.filter(project=self.project).aggregate( + smallest=models.Min("sort_order") + )["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 @@ -99,7 +102,7 @@ class CycleIssue(ProjectBaseModel): Cycle Issues """ - issue = models.OneToOneField( + issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_cycle" ) cycle = models.ForeignKey( @@ -107,6 +110,14 @@ class CycleIssue(ProjectBaseModel): ) class Meta: + unique_together = ["issue", "cycle", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["cycle", "issue"], + condition=models.Q(deleted_at__isnull=True), + name="cycle_issue_when_deleted_at_null", + ) + ] verbose_name = "Cycle Issue" verbose_name_plural = "Cycle Issues" db_table = "cycle_issues" @@ -116,38 +127,9 @@ class CycleIssue(ProjectBaseModel): return f"{self.cycle}" -# DEPRECATED TODO: - Remove in next release -class CycleFavorite(ProjectBaseModel): - """_summary_ - CycleFavorite (model): To store all the cycle favorite of the user - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="cycle_favorites", - ) - cycle = models.ForeignKey( - "db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites" - ) - - class Meta: - unique_together = ["cycle", "user"] - verbose_name = "Cycle Favorite" - verbose_name_plural = "Cycle Favorites" - db_table = "cycle_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the cycle""" - return f"{self.user.email} <{self.cycle.name}>" - - class CycleUserProperties(ProjectBaseModel): cycle = models.ForeignKey( - "db.Cycle", - on_delete=models.CASCADE, - related_name="cycle_user_properties", + "db.Cycle", on_delete=models.CASCADE, related_name="cycle_user_properties" ) user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -156,9 +138,7 @@ class CycleUserProperties(ProjectBaseModel): ) filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField( - default=get_default_display_properties - ) + display_properties = models.JSONField(default=get_default_display_properties) class Meta: unique_together = ["cycle", "user", "deleted_at"] diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py index b9c3c45b0..e0f401753 100644 --- a/apiserver/plane/db/models/dashboard.py +++ b/apiserver/plane/db/models/dashboard.py @@ -20,9 +20,7 @@ class Dashboard(BaseModel): description_html = models.TextField(blank=True, default="

") identifier = models.UUIDField(null=True) owned_by = models.ForeignKey( - "db.User", - on_delete=models.CASCADE, - related_name="dashboards", + "db.User", on_delete=models.CASCADE, related_name="dashboards" ) is_default = models.BooleanField(default=False) type_identifier = models.CharField( @@ -46,11 +44,7 @@ class Dashboard(BaseModel): class Widget(TimeAuditModel): id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) key = models.CharField(max_length=255) filters = models.JSONField(default=dict) @@ -69,14 +63,10 @@ class Widget(TimeAuditModel): class DashboardWidget(BaseModel): widget = models.ForeignKey( - Widget, - on_delete=models.CASCADE, - related_name="dashboard_widgets", + Widget, on_delete=models.CASCADE, related_name="dashboard_widgets" ) dashboard = models.ForeignKey( - Dashboard, - on_delete=models.CASCADE, - related_name="dashboard_widgets", + Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets" ) is_visible = models.BooleanField(default=True) sort_order = models.FloatField(default=65535) diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py index da9476f16..f053f4a82 100644 --- a/apiserver/plane/db/models/deploy_board.py +++ b/apiserver/plane/db/models/deploy_board.py @@ -20,27 +20,23 @@ class DeployBoard(WorkspaceBaseModel): ("cycle", "Task"), ("page", "Page"), ("view", "View"), + ("intake", "Intake"), ) entity_identifier = models.UUIDField(null=True) - entity_name = models.CharField( - max_length=30, - choices=TYPE_CHOICES, - ) + entity_name = models.CharField(max_length=30, null=True, blank=True) 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, + intake = models.ForeignKey( + "db.Intake", related_name="publish_intake", on_delete=models.SET_NULL, null=True ) is_votes_enabled = models.BooleanField(default=False) view_props = models.JSONField(default=dict) is_activity_enabled = models.BooleanField(default=True) + is_disabled = models.BooleanField(default=False) def __str__(self): """Return name of the deploy board""" diff --git a/apiserver/plane/db/models/device.py b/apiserver/plane/db/models/device.py new file mode 100644 index 000000000..055d8ccc4 --- /dev/null +++ b/apiserver/plane/db/models/device.py @@ -0,0 +1,44 @@ +# models.py +from django.db import models +from django.conf import settings +from .base import BaseModel + + +class Device(BaseModel): + class DeviceType(models.TextChoices): + ANDROID = "ANDROID", "Android" + IOS = "IOS", "iOS" + WEB = "WEB", "Web" + DESKTOP = "DESKTOP", "Desktop" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="devices" + ) + device_id = models.CharField(max_length=255, blank=True, null=True) + device_type = models.CharField(max_length=255, choices=DeviceType.choices) + push_token = models.CharField(max_length=255, blank=True, null=True) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "devices" + verbose_name = "Device" + verbose_name_plural = "Devices" + + +class DeviceSession(BaseModel): + device = models.ForeignKey( + Device, on_delete=models.CASCADE, related_name="sessions" + ) + session = models.ForeignKey( + "db.Session", on_delete=models.CASCADE, related_name="device_sessions" + ) + is_active = models.BooleanField(default=True) + user_agent = models.CharField(max_length=255, null=True, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + start_time = models.DateTimeField(auto_now_add=True) + end_time = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "device_sessions" + verbose_name = "Device Session" + verbose_name_plural = "Device Sessions" diff --git a/apiserver/plane/db/models/draft.py b/apiserver/plane/db/models/draft.py new file mode 100644 index 000000000..42148d5bb --- /dev/null +++ b/apiserver/plane/db/models/draft.py @@ -0,0 +1,240 @@ +# Django imports +from django.conf import settings +from django.db import models +from django.utils import timezone + +# Module imports +from plane.utils.html_processor import strip_tags + +from .workspace import WorkspaceBaseModel + + +class DraftIssue(WorkspaceBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="draft_parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_draft_issue", + ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="draft_issue_estimates", + null=True, + blank=True, + ) + name = models.CharField( + max_length=255, verbose_name="Issue Name", blank=True, null=True + ) + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_binary = models.BinaryField(null=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="draft_assignee", + through="DraftIssueAssignee", + through_fields=("draft_issue", "assignee"), + ) + labels = models.ManyToManyField( + "db.Label", blank=True, related_name="draft_labels", through="DraftIssueLabel" + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + type = models.ForeignKey( + "db.IssueType", + on_delete=models.SET_NULL, + related_name="draft_issue_type", + null=True, + blank=True, + ) + + class Meta: + verbose_name = "DraftIssue" + verbose_name_plural = "DraftIssues" + db_table = "draft_issues" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.state is None: + try: + from plane.db.models import State + + default_state = State.objects.filter( + ~models.Q(is_triage=True), project=self.project, default=True + ).first() + if default_state is None: + random_state = State.objects.filter( + ~models.Q(is_triage=True), project=self.project + ).first() + self.state = random_state + else: + self.state = default_state + except ImportError: + pass + else: + try: + from plane.db.models import State + + if self.state.group == "completed": + self.completed_at = timezone.now() + else: + self.completed_at = None + except ImportError: + pass + + if self._state.adding: + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + largest_sort_order = DraftIssue.objects.filter( + project=self.project, state=self.state + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(DraftIssue, self).save(*args, **kwargs) + + else: + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + super(DraftIssue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the draft issue""" + return f"{self.name} <{self.project.name}>" + + +class DraftIssueAssignee(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + DraftIssue, on_delete=models.CASCADE, related_name="draft_issue_assignee" + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="draft_issue_assignee", + ) + + class Meta: + unique_together = ["draft_issue", "assignee", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "assignee"], + condition=models.Q(deleted_at__isnull=True), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Assignee" + verbose_name_plural = "Draft Issue Assignees" + db_table = "draft_issue_assignees" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.assignee.email}" + + +class DraftIssueLabel(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_label_issue" + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="draft_label_issue" + ) + + class Meta: + verbose_name = "Draft Issue Label" + verbose_name_plural = "Draft Issue Labels" + db_table = "draft_issue_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.label.name}" + + +class DraftIssueModule(WorkspaceBaseModel): + module = models.ForeignKey( + "db.Module", on_delete=models.CASCADE, related_name="draft_issue_module" + ) + draft_issue = models.ForeignKey( + "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_module" + ) + + class Meta: + unique_together = ["draft_issue", "module", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "module"], + condition=models.Q(deleted_at__isnull=True), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Module" + verbose_name_plural = "Draft Issue Modules" + db_table = "draft_issue_modules" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.draft_issue.name}" + + +class DraftIssueCycle(WorkspaceBaseModel): + """ + Draft Issue Cycles + """ + + draft_issue = models.ForeignKey( + "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_cycle" + ) + cycle = models.ForeignKey( + "db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle" + ) + + class Meta: + unique_together = ["draft_issue", "cycle", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "cycle"], + condition=models.Q(deleted_at__isnull=True), + name="draft_issue_cycle_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Cycle" + verbose_name_plural = "Draft Issue Cycles" + db_table = "draft_issue_cycles" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index eb2eaf49b..b0097562d 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -9,9 +9,7 @@ from .project import ProjectBaseModel class Estimate(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField( - verbose_name="Estimate Description", blank=True - ) + description = models.TextField(verbose_name="Estimate Description", blank=True) type = models.CharField(max_length=255, default="categories") last_used = models.BooleanField(default=False) @@ -36,9 +34,7 @@ class Estimate(ProjectBaseModel): class EstimatePoint(ProjectBaseModel): estimate = models.ForeignKey( - "db.Estimate", - on_delete=models.CASCADE, - related_name="points", + "db.Estimate", on_delete=models.CASCADE, related_name="points" ) key = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py index d26f03450..48d40a1aa 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -30,20 +30,11 @@ class ExporterHistory(BaseModel): ), ) workspace = models.ForeignKey( - "db.WorkSpace", - on_delete=models.CASCADE, - related_name="workspace_exporters", - ) - project = ArrayField( - models.UUIDField(default=uuid.uuid4), blank=True, null=True + "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters" ) + project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True) provider = models.CharField( - max_length=50, - choices=( - ("json", "json"), - ("csv", "csv"), - ("xlsx", "xlsx"), - ), + max_length=50, choices=(("json", "json"), ("csv", "csv"), ("xlsx", "xlsx")) ) status = models.CharField( max_length=50, @@ -58,9 +49,7 @@ class ExporterHistory(BaseModel): reason = models.TextField(blank=True) key = models.TextField(blank=True) url = models.URLField(max_length=800, blank=True, null=True) - token = models.CharField( - max_length=255, default=generate_token, unique=True - ) + token = models.CharField(max_length=255, default=generate_token, unique=True) initiated_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apiserver/plane/db/models/favorite.py b/apiserver/plane/db/models/favorite.py index b921a8bd6..680bf7e37 100644 --- a/apiserver/plane/db/models/favorite.py +++ b/apiserver/plane/db/models/favorite.py @@ -13,9 +13,7 @@ class UserFavorite(WorkspaceBaseModel): """ user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="favorites", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites" ) entity_type = models.CharField(max_length=100) entity_identifier = models.UUIDField(null=True, blank=True) @@ -31,12 +29,7 @@ class UserFavorite(WorkspaceBaseModel): ) class Meta: - unique_together = [ - "entity_type", - "user", - "entity_identifier", - "deleted_at", - ] + unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"] constraints = [ models.UniqueConstraint( fields=["entity_type", "entity_identifier", "user"], @@ -57,7 +50,7 @@ class UserFavorite(WorkspaceBaseModel): ).aggregate(largest=models.Max("sequence"))["largest"] else: largest_sequence = UserFavorite.objects.filter( - workspace=self.workspace, + workspace=self.workspace ).aggregate(largest=models.Max("sequence"))["largest"] if largest_sequence is not None: self.sequence = largest_sequence + 10000 diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py index ebc7571d5..df93b95d1 100644 --- a/apiserver/plane/db/models/importer.py +++ b/apiserver/plane/db/models/importer.py @@ -8,11 +8,7 @@ from .project import ProjectBaseModel class Importer(ProjectBaseModel): service = models.CharField( - max_length=50, - choices=( - ("github", "GitHub"), - ("jira", "Jira"), - ), + max_length=50, choices=(("github", "GitHub"), ("jira", "Jira")) ) status = models.CharField( max_length=50, @@ -25,9 +21,7 @@ class Importer(ProjectBaseModel): default="queued", ) initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="imports", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports" ) metadata = models.JSONField(default=dict) config = models.JSONField(default=dict) diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/intake.py similarity index 59% rename from apiserver/plane/db/models/inbox.py rename to apiserver/plane/db/models/intake.py index be2b1f3dd..3860b97f2 100644 --- a/apiserver/plane/db/models/inbox.py +++ b/apiserver/plane/db/models/intake.py @@ -5,17 +5,15 @@ from django.db import models from plane.db.models.project import ProjectBaseModel -class Inbox(ProjectBaseModel): +class Intake(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField( - verbose_name="Inbox Description", blank=True - ) + description = models.TextField(verbose_name="Intake Description", blank=True) is_default = models.BooleanField(default=False) view_props = models.JSONField(default=dict) logo_props = models.JSONField(default=dict) def __str__(self): - """Return name of the Inbox""" + """Return name of the intake""" return f"{self.name} <{self.project.name}>" class Meta: @@ -24,21 +22,21 @@ class Inbox(ProjectBaseModel): models.UniqueConstraint( fields=["name", "project"], condition=models.Q(deleted_at__isnull=True), - name="inbox_unique_name_project_when_deleted_at_null", + name="intake_unique_name_project_when_deleted_at_null", ) ] - verbose_name = "Inbox" - verbose_name_plural = "Inboxes" - db_table = "inboxes" + verbose_name = "Intake" + verbose_name_plural = "Intakes" + db_table = "intakes" ordering = ("name",) -class InboxIssue(ProjectBaseModel): - inbox = models.ForeignKey( - "db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE +class IntakeIssue(ProjectBaseModel): + intake = models.ForeignKey( + "db.Intake", related_name="issue_intake", on_delete=models.CASCADE ) issue = models.ForeignKey( - "db.Issue", related_name="issue_inbox", on_delete=models.CASCADE + "db.Issue", related_name="issue_intake", on_delete=models.CASCADE ) status = models.IntegerField( choices=( @@ -53,20 +51,22 @@ class InboxIssue(ProjectBaseModel): snoozed_till = models.DateTimeField(null=True) duplicate_to = models.ForeignKey( "db.Issue", - related_name="inbox_duplicate", + related_name="intake_duplicate", on_delete=models.SET_NULL, null=True, ) - source = models.TextField(blank=True, null=True) + source = models.CharField(max_length=255, default="IN_APP", null=True, blank=True) + source_email = models.TextField(blank=True, null=True) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + extra = models.JSONField(default=dict) class Meta: - verbose_name = "InboxIssue" - verbose_name_plural = "InboxIssues" - db_table = "inbox_issues" + verbose_name = "IntakeIssue" + verbose_name_plural = "IntakeIssues" + db_table = "intake_issues" ordering = ("-created_at",) def __str__(self): """Return name of the Issue""" - return f"{self.issue.name} <{self.inbox.name}>" + return f"{self.issue.name} <{self.intake.name}>" diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py index 0c68adfd2..61dad67b0 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -11,11 +11,7 @@ from plane.db.mixins import AuditModel class Integration(AuditModel): id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) title = models.CharField(max_length=400) provider = models.CharField(max_length=400, unique=True) @@ -29,7 +25,7 @@ class Integration(AuditModel): redirect_url = models.TextField(blank=True) metadata = models.JSONField(default=dict) verified = models.BooleanField(default=False) - avatar_url = models.URLField(blank=True, null=True) + avatar_url = models.TextField(blank=True, null=True) def __str__(self): """Return provider of the integration""" @@ -44,18 +40,14 @@ class Integration(AuditModel): class WorkspaceIntegration(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - related_name="workspace_integrations", - on_delete=models.CASCADE, + "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE ) # Bot user actor = models.ForeignKey( "db.User", related_name="integrations", on_delete=models.CASCADE ) integration = models.ForeignKey( - "db.Integration", - related_name="integrated_workspaces", - on_delete=models.CASCADE, + "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE ) api_token = models.ForeignKey( "db.APIToken", related_name="integrations", on_delete=models.CASCADE diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py index 9e4294175..410972404 100644 --- a/apiserver/plane/db/models/integration/github.py +++ b/apiserver/plane/db/models/integration/github.py @@ -35,15 +35,10 @@ class GithubRepositorySync(ProjectBaseModel): "db.User", related_name="user_syncs", on_delete=models.CASCADE ) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", - related_name="github_syncs", - on_delete=models.CASCADE, + "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE ) label = models.ForeignKey( - "db.Label", - on_delete=models.SET_NULL, - null=True, - related_name="repo_syncs", + "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" ) def __str__(self): @@ -66,9 +61,7 @@ class GithubIssueSync(ProjectBaseModel): "db.Issue", related_name="github_syncs", on_delete=models.CASCADE ) repository_sync = models.ForeignKey( - "db.GithubRepositorySync", - related_name="issue_syncs", - on_delete=models.CASCADE, + "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE ) def __str__(self): @@ -86,14 +79,10 @@ class GithubIssueSync(ProjectBaseModel): class GithubCommentSync(ProjectBaseModel): repo_comment_id = models.BigIntegerField() comment = models.ForeignKey( - "db.IssueComment", - related_name="comment_syncs", - on_delete=models.CASCADE, + "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE ) issue_sync = models.ForeignKey( - "db.GithubIssueSync", - related_name="comment_syncs", - on_delete=models.CASCADE, + "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE ) def __str__(self): diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py index 94d5d7d83..1e8ea469b 100644 --- a/apiserver/plane/db/models/integration/slack.py +++ b/apiserver/plane/db/models/integration/slack.py @@ -16,9 +16,7 @@ class SlackProjectSync(ProjectBaseModel): team_id = models.CharField(max_length=30) team_name = models.CharField(max_length=300) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", - related_name="slack_syncs", - on_delete=models.CASCADE, + "db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE ) def __str__(self): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index cafa732c5..e50dbe7ce 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -9,10 +9,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.utils import timezone from django.db.models import Q +from django import apps # Module imports from plane.utils.html_processor import strip_tags - +from plane.db.mixins import SoftDeletionManager +from plane.utils.exception_logger import log_exception from .project import ProjectBaseModel @@ -79,16 +81,16 @@ def get_default_display_properties(): # TODO: Handle identifiers for Bulk Inserts - nk -class IssueManager(models.Manager): +class IssueManager(SoftDeletionManager): def get_queryset(self): return ( super() .get_queryset() .filter( - models.Q(issue_inbox__status=1) - | models.Q(issue_inbox__status=-1) - | models.Q(issue_inbox__status=2) - | models.Q(issue_inbox__isnull=True) + models.Q(issue_intake__status=1) + | models.Q(issue_intake__status=-1) + | models.Q(issue_intake__status=2) + | models.Q(issue_intake__isnull=True) ) .filter(deleted_at__isnull=True) .filter(state__is_triage=False) @@ -121,9 +123,7 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) point = models.IntegerField( - validators=[MinValueValidator(0), MaxValueValidator(12)], - null=True, - blank=True, + validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True ) estimate_point = models.ForeignKey( "db.EstimatePoint", @@ -152,9 +152,7 @@ class Issue(ProjectBaseModel): through="IssueAssignee", through_fields=("issue", "assignee"), ) - sequence_id = models.IntegerField( - default=1, verbose_name="Issue Sequence ID" - ) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") labels = models.ManyToManyField( "db.Label", blank=True, related_name="labels", through="IssueLabel" ) @@ -172,7 +170,6 @@ class Issue(ProjectBaseModel): blank=True, ) - objects = models.Manager() issue_objects = IssueManager() class Meta: @@ -187,9 +184,7 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - ~models.Q(is_triage=True), - project=self.project, - default=True, + ~models.Q(is_triage=True), project=self.project, default=True ).first() if default_state is None: random_state = State.objects.filter( @@ -222,10 +217,7 @@ class Issue(ProjectBaseModel): # Strip the html tags using html parser self.description_stripped = ( None - if ( - self.description_html == "" - or self.description_html is None - ) + if (self.description_html == "" or self.description_html is None) else strip_tags(self.description_html) ) largest_sort_order = Issue.objects.filter( @@ -243,10 +235,7 @@ class Issue(ProjectBaseModel): # Strip the html tags using html parser self.description_stripped = ( None - if ( - self.description_html == "" - or self.description_html is None - ) + if (self.description_html == "" or self.description_html is None) else strip_tags(self.description_html) ) super(Issue, self).save(*args, **kwargs) @@ -279,6 +268,8 @@ class IssueRelation(ProjectBaseModel): ("duplicate", "Duplicate"), ("relates_to", "Relates To"), ("blocked_by", "Blocked By"), + ("start_before", "Start Before"), + ("finish_before", "Finish Before"), ) issue = models.ForeignKey( @@ -317,9 +308,7 @@ class IssueMention(ProjectBaseModel): Issue, on_delete=models.CASCADE, related_name="issue_mention" ) mention = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_mention", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention" ) class Meta: @@ -398,12 +387,7 @@ def file_size(value): class IssueAttachment(ProjectBaseModel): attributes = models.JSONField(default=dict) - asset = models.FileField( - upload_to=get_upload_path, - validators=[ - file_size, - ], - ) + asset = models.FileField(upload_to=get_upload_path, validators=[file_size]) issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" ) @@ -422,28 +406,17 @@ class IssueAttachment(ProjectBaseModel): class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( - Issue, - on_delete=models.SET_NULL, - null=True, - related_name="issue_activity", - ) - verb = models.CharField( - max_length=255, verbose_name="Action", default="created" + Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" ) + verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField( max_length=255, verbose_name="Field Name", blank=True, null=True ) - old_value = models.TextField( - verbose_name="Old Value", blank=True, null=True - ) - new_value = models.TextField( - verbose_name="New Value", blank=True, null=True - ) + old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) + new_value = models.TextField(verbose_name="New Value", blank=True, null=True) comment = models.TextField(verbose_name="Comment", blank=True) - attachments = ArrayField( - models.URLField(), size=10, blank=True, default=list - ) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) issue_comment = models.ForeignKey( "db.IssueComment", on_delete=models.SET_NULL, @@ -475,9 +448,7 @@ class IssueComment(ProjectBaseModel): comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") - attachments = ArrayField( - models.URLField(), size=10, blank=True, default=list - ) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_comments" ) @@ -489,10 +460,7 @@ class IssueComment(ProjectBaseModel): null=True, ) access = models.CharField( - choices=( - ("INTERNAL", "INTERNAL"), - ("EXTERNAL", "EXTERNAL"), - ), + choices=(("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")), default="INTERNAL", max_length=100, ) @@ -524,9 +492,7 @@ class IssueUserProperty(ProjectBaseModel): ) filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField( - default=get_default_display_properties - ) + display_properties = models.JSONField(default=get_default_display_properties) class Meta: verbose_name = "Issue User Property" @@ -547,51 +513,6 @@ class IssueUserProperty(ProjectBaseModel): return str(self.user) -class Label(ProjectBaseModel): - parent = models.ForeignKey( - "self", - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="parent_label", - ) - name = models.CharField(max_length=255) - description = models.TextField(blank=True) - color = models.CharField(max_length=255, blank=True) - sort_order = models.FloatField(default=65535) - external_source = models.CharField(max_length=255, null=True, blank=True) - external_id = models.CharField(max_length=255, blank=True, null=True) - - class Meta: - unique_together = ["name", "project", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["name", "project"], - condition=Q(deleted_at__isnull=True), - name="label_unique_name_project_when_deleted_at_null", - ) - ] - verbose_name = "Label" - verbose_name_plural = "Labels" - db_table = "labels" - ordering = ("-created_at",) - - def save(self, *args, **kwargs): - if self._state.adding: - # Get the maximum sequence value from the database - last_id = Label.objects.filter(project=self.project).aggregate( - largest=models.Max("sort_order") - )["largest"] - # if last_id is not None - if last_id is not None: - self.sort_order = last_id + 10000 - - super(Label, self).save(*args, **kwargs) - - def __str__(self): - return str(self.name) - - class IssueLabel(ProjectBaseModel): issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="label_issue" @@ -691,9 +612,7 @@ class CommentReaction(ProjectBaseModel): related_name="comment_reactions", ) comment = models.ForeignKey( - IssueComment, - on_delete=models.CASCADE, - related_name="comment_reactions", + IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" ) reaction = models.CharField(max_length=20) @@ -716,28 +635,14 @@ class CommentReaction(ProjectBaseModel): class IssueVote(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="votes" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="votes", - ) - vote = models.IntegerField( - choices=( - (-1, "DOWNVOTE"), - (1, "UPVOTE"), - ), - default=1, + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" ) + vote = models.IntegerField(choices=((-1, "DOWNVOTE"), (1, "UPVOTE")), default=1) class Meta: - unique_together = [ - "issue", - "actor", - "deleted_at", - ] + unique_together = ["issue", "actor", "deleted_at"] constraints = [ models.UniqueConstraint( fields=["issue", "actor"], @@ -752,3 +657,126 @@ class IssueVote(ProjectBaseModel): def __str__(self): return f"{self.issue.name} {self.actor.email}" + + +class IssueVersion(ProjectBaseModel): + issue = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + related_name="versions", + ) + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.UUIDField(blank=True, null=True) + state = models.UUIDField(blank=True, null=True) + estimate_point = models.UUIDField(blank=True, null=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="

") + description_stripped = models.TextField(blank=True, null=True) + description_binary = models.BinaryField(null=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + sequence_id = models.IntegerField( + default=1, verbose_name="Issue Sequence ID" + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + type = models.UUIDField(blank=True, null=True) + last_saved_at = models.DateTimeField(default=timezone.now) + owned_by = models.UUIDField() + assignees = ArrayField( + models.UUIDField(), + blank=True, + default=list, + ) + labels = ArrayField( + models.UUIDField(), + blank=True, + default=list, + ) + cycle = models.UUIDField( + null=True, + blank=True, + ) + modules = ArrayField( + models.UUIDField(), + blank=True, + default=list, + ) + properties = models.JSONField(default=dict) + meta = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue Version" + verbose_name_plural = "Issue Versions" + db_table = "issue_versions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} <{self.project.name}>" + + @classmethod + def log_issue_version(cls, issue, user): + try: + """ + Log the issue version + """ + + Module = apps.get_model("db.Module") + CycleIssue = apps.get_model("db.CycleIssue") + + cycle_issue = CycleIssue.objects.filter( + issue=issue, + ).first() + + cls.objects.create( + issue=issue, + parent=issue.parent, + state=issue.state, + point=issue.point, + estimate_point=issue.estimate_point, + name=issue.name, + description=issue.description, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_binary=issue.description_binary, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + sequence_id=issue.sequence_id, + sort_order=issue.sort_order, + completed_at=issue.completed_at, + archived_at=issue.archived_at, + is_draft=issue.is_draft, + external_source=issue.external_source, + external_id=issue.external_id, + type=issue.type, + last_saved_at=issue.last_saved_at, + assignees=issue.assignees, + labels=issue.labels, + cycle=cycle_issue.cycle if cycle_issue else None, + modules=Module.objects.filter(issue=issue).values_list( + "id", flat=True + ), + owned_by=user, + ) + return True + except Exception as e: + log_exception(e) + return False diff --git a/apiserver/plane/db/models/issue_type.py b/apiserver/plane/db/models/issue_type.py index f62cf54b5..953afcc8b 100644 --- a/apiserver/plane/db/models/issue_type.py +++ b/apiserver/plane/db/models/issue_type.py @@ -9,16 +9,17 @@ from .base import BaseModel class IssueType(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - related_name="issue_types", - on_delete=models.CASCADE, + "db.Workspace", related_name="issue_types", on_delete=models.CASCADE ) name = models.CharField(max_length=255) description = models.TextField(blank=True) logo_props = models.JSONField(default=dict) + is_epic = models.BooleanField(default=False) is_default = models.BooleanField(default=False) is_active = models.BooleanField(default=True) - level = models.PositiveIntegerField(default=0) + level = models.FloatField(default=0) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) class Meta: verbose_name = "Issue Type" @@ -31,9 +32,7 @@ class IssueType(BaseModel): class ProjectIssueType(ProjectBaseModel): issue_type = models.ForeignKey( - "db.IssueType", - related_name="project_issue_types", - on_delete=models.CASCADE, + "db.IssueType", related_name="project_issue_types", on_delete=models.CASCADE ) level = models.PositiveIntegerField(default=0) is_default = models.BooleanField(default=False) diff --git a/apiserver/plane/db/models/label.py b/apiserver/plane/db/models/label.py new file mode 100644 index 000000000..11e2da8c3 --- /dev/null +++ b/apiserver/plane/db/models/label.py @@ -0,0 +1,55 @@ +from django.db import models +from django.db.models import Q + +from .workspace import WorkspaceBaseModel + + +class Label(WorkspaceBaseModel): + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_label", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + color = models.CharField(max_length=255, blank=True) + sort_order = models.FloatField(default=65535) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + constraints = [ + # Enforce uniqueness of name when project is NULL and deleted_at is NULL + models.UniqueConstraint( + fields=["name"], + condition=Q(project__isnull=True, deleted_at__isnull=True), + name="unique_name_when_project_null_and_not_deleted", + ), + # Enforce uniqueness of project and name when project is not NULL and deleted_at is NULL + models.UniqueConstraint( + fields=["project", "name"], + condition=Q(project__isnull=False, deleted_at__isnull=True), + name="unique_project_name_when_not_deleted", + ), + ] + verbose_name = "Label" + verbose_name_plural = "Labels" + db_table = "labels" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + # Get the maximum sequence value from the database + last_id = Label.objects.filter(project=self.project).aggregate( + largest=models.Max("sort_order") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sort_order = last_id + 10000 + + super(Label, self).save(*args, **kwargs) + + def __str__(self): + return str(self.name) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 7c1fff53e..6fba4d03c 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -53,9 +53,7 @@ def get_default_display_properties(): class Module(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Module Name") - description = models.TextField( - verbose_name="Module Description", blank=True - ) + description = models.TextField(verbose_name="Module Description", blank=True) description_text = models.JSONField( verbose_name="Module Description RT", blank=True, null=True ) @@ -77,10 +75,7 @@ class Module(ProjectBaseModel): max_length=20, ) lead = models.ForeignKey( - "db.User", - on_delete=models.SET_NULL, - related_name="module_leads", - null=True, + "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True ) members = models.ManyToManyField( settings.AUTH_USER_MODEL, @@ -100,9 +95,9 @@ class Module(ProjectBaseModel): unique_together = ["name", "project", "deleted_at"] constraints = [ models.UniqueConstraint( - fields=['name', 'project'], + fields=["name", "project"], condition=Q(deleted_at__isnull=True), - name='module_unique_name_project_when_deleted_at_null' + name="module_unique_name_project_when_deleted_at_null", ) ] verbose_name = "Module" @@ -112,9 +107,9 @@ class Module(ProjectBaseModel): def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Module.objects.filter( - project=self.project - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + smallest_sort_order = Module.objects.filter(project=self.project).aggregate( + smallest=models.Min("sort_order") + )["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 @@ -191,38 +186,9 @@ class ModuleLink(ProjectBaseModel): return f"{self.module.name} {self.url}" -# DEPRECATED TODO: - Remove in next release -class ModuleFavorite(ProjectBaseModel): - """_summary_ - ModuleFavorite (model): To store all the module favorite of the user - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="module_favorites", - ) - module = models.ForeignKey( - "db.Module", on_delete=models.CASCADE, related_name="module_favorites" - ) - - class Meta: - unique_together = ["module", "user"] - verbose_name = "Module Favorite" - verbose_name_plural = "Module Favorites" - db_table = "module_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the module""" - return f"{self.user.email} <{self.module.name}>" - - class ModuleUserProperties(ProjectBaseModel): module = models.ForeignKey( - "db.Module", - on_delete=models.CASCADE, - related_name="module_user_properties", + "db.Module", on_delete=models.CASCADE, related_name="module_user_properties" ) user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -231,9 +197,7 @@ class ModuleUserProperties(ProjectBaseModel): ) filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField( - default=get_default_display_properties - ) + display_properties = models.JSONField(default=get_default_display_properties) class Meta: unique_together = ["module", "user", "deleted_at"] diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 33241e05d..2847c07cf 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -6,16 +6,12 @@ from django.db import models from .base import BaseModel - class Notification(BaseModel): workspace = models.ForeignKey( "db.Workspace", related_name="notifications", on_delete=models.CASCADE ) project = models.ForeignKey( - "db.Project", - related_name="notifications", - on_delete=models.CASCADE, - null=True, + "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True ) data = models.JSONField(null=True) entity_identifier = models.UUIDField(null=True) @@ -32,9 +28,7 @@ class Notification(BaseModel): null=True, ) receiver = models.ForeignKey( - "db.User", - related_name="received_notifications", - on_delete=models.CASCADE, + "db.User", related_name="received_notifications", on_delete=models.CASCADE ) read_at = models.DateTimeField(null=True) snoozed_till = models.DateTimeField(null=True) @@ -53,18 +47,10 @@ class Notification(BaseModel): def get_default_preference(): return { - "property_change": { - "email": True, - }, - "state": { - "email": True, - }, - "comment": { - "email": True, - }, - "mentions": { - "email": True, - }, + "property_change": {"email": True}, + "state": {"email": True}, + "comment": {"email": True}, + "mentions": {"email": True}, } diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 5a7f3b001..81e2b15a0 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -9,7 +9,6 @@ from django.db import models # Module imports from plane.utils.html_processor import strip_tags -from .project import ProjectBaseModel from .base import BaseModel @@ -27,9 +26,7 @@ class Page(BaseModel): description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="pages", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages" ) access = models.PositiveSmallIntegerField( choices=((0, "Public"), (1, "Private")), default=0 @@ -53,9 +50,6 @@ class Page(BaseModel): 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" @@ -93,19 +87,13 @@ class PageLog(BaseModel): ("user_mention", "User Mention"), ) transaction = models.UUIDField(default=uuid.uuid4) - page = models.ForeignKey( - Page, related_name="page_log", on_delete=models.CASCADE - ) + page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField( - max_length=30, - choices=TYPE_CHOICES, - verbose_name="Transaction Type", + max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type" ) workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="workspace_page_log", + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" ) class Meta: @@ -119,86 +107,6 @@ class PageLog(BaseModel): return f"{self.page.name} {self.entity_name}" -# DEPRECATED TODO: - Remove in next release -class PageBlock(ProjectBaseModel): - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="blocks" - ) - name = models.CharField(max_length=255) - description = models.JSONField(default=dict, blank=True) - description_html = models.TextField(blank=True, default="

") - description_stripped = models.TextField(blank=True, null=True) - issue = models.ForeignKey( - "db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True - ) - completed_at = models.DateTimeField(null=True) - sort_order = models.FloatField(default=65535) - sync = models.BooleanField(default=True) - - def save(self, *args, **kwargs): - if self._state.adding: - largest_sort_order = PageBlock.objects.filter( - project=self.project, page=self.page - ).aggregate(largest=models.Max("sort_order"))["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 - - # Strip the html tags using html parser - self.description_stripped = ( - None - if (self.description_html == "" or self.description_html is None) - else strip_tags(self.description_html) - ) - - if self.completed_at and self.issue: - try: - from plane.db.models import Issue, State - - completed_state = State.objects.filter( - group="completed", project=self.project - ).first() - if completed_state is not None: - Issue.objects.update( - pk=self.issue_id, state=completed_state - ) - except ImportError: - pass - super(PageBlock, self).save(*args, **kwargs) - - class Meta: - verbose_name = "Page Block" - verbose_name_plural = "Page Blocks" - db_table = "page_blocks" - ordering = ("-created_at",) - - def __str__(self): - """Return page and page block""" - return f"{self.page.name} <{self.name}>" - - -# DEPRECATED TODO: - Remove in next release -class PageFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="page_favorites", - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="page_favorites" - ) - - class Meta: - unique_together = ["page", "user"] - verbose_name = "Page Favorite" - verbose_name_plural = "Page Favorites" - db_table = "page_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the page""" - return f"{self.user.email} <{self.page.name}>" - - class PageLabel(BaseModel): label = models.ForeignKey( "db.Label", on_delete=models.CASCADE, related_name="page_labels" @@ -207,9 +115,7 @@ class PageLabel(BaseModel): "db.Page", on_delete=models.CASCADE, related_name="page_labels" ) workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="workspace_page_label", + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_label" ) class Meta: @@ -251,48 +157,16 @@ class ProjectPage(BaseModel): 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", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["team", "page"], - condition=models.Q(deleted_at__isnull=True), - name="team_page_unique_team_page_when_deleted_at_null", - ) - ] - verbose_name = "Team Page" - verbose_name_plural = "Team Pages" - db_table = "team_pages" - ordering = ("-created_at",) - - class PageVersion(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="page_versions", + "db.Workspace", on_delete=models.CASCADE, related_name="page_versions" ) page = models.ForeignKey( - "db.Page", - on_delete=models.CASCADE, - related_name="page_versions", + "db.Page", on_delete=models.CASCADE, related_name="page_versions" ) last_saved_at = models.DateTimeField(default=timezone.now) owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="page_versions", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="page_versions" ) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index bcc168227..c97c550ee 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -1,4 +1,5 @@ # Python imports +import pytz from uuid import uuid4 # Django imports @@ -7,17 +8,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q -# Modeule imports +# Module imports from plane.db.mixins import AuditModel # Module imports from .base import BaseModel -ROLE_CHOICES = ( - (20, "Admin"), - (15, "Member"), - (5, "Guest"), -) +ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) def get_default_props(): @@ -52,27 +49,19 @@ def get_default_preferences(): class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") - description = models.TextField( - verbose_name="Project Description", blank=True - ) + description = models.TextField(verbose_name="Project Description", blank=True) description_text = models.JSONField( verbose_name="Project Description RT", blank=True, null=True ) description_html = models.JSONField( verbose_name="Project Description HTML", blank=True, null=True ) - network = models.PositiveSmallIntegerField( - default=2, choices=NETWORK_CHOICES - ) + network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) workspace = models.ForeignKey( - "db.WorkSpace", - on_delete=models.CASCADE, - related_name="workspace_project", + "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" ) identifier = models.CharField( - max_length=12, - verbose_name="Project Identifier", - db_index=True, + max_length=12, verbose_name="Project Identifier", db_index=True ) default_assignee = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -94,16 +83,20 @@ class Project(BaseModel): cycle_view = models.BooleanField(default=True) issue_views_view = models.BooleanField(default=True) page_view = models.BooleanField(default=True) - inbox_view = models.BooleanField(default=False) + intake_view = models.BooleanField(default=False) is_time_tracking_enabled = models.BooleanField(default=False) is_issue_type_enabled = models.BooleanField(default=False) guest_view_all_features = models.BooleanField(default=False) - cover_image = models.URLField(blank=True, null=True, max_length=800) - estimate = models.ForeignKey( - "db.Estimate", + cover_image = models.TextField(blank=True, null=True) + cover_image_asset = models.ForeignKey( + "db.FileAsset", on_delete=models.SET_NULL, - related_name="projects", null=True, + blank=True, + related_name="project_cover_image", + ) + estimate = models.ForeignKey( + "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True ) archive_in = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] @@ -113,12 +106,24 @@ class Project(BaseModel): ) logo_props = models.JSONField(default=dict) default_state = models.ForeignKey( - "db.State", - on_delete=models.SET_NULL, - null=True, - related_name="default_state", + "db.State", on_delete=models.SET_NULL, null=True, related_name="default_state" ) archived_at = models.DateTimeField(null=True) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + + @property + def cover_image_url(self): + # Return cover image url + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return cover image url + if self.cover_image: + return self.cover_image + + return None def __str__(self): """Return name of the project""" @@ -156,7 +161,7 @@ class ProjectBaseModel(BaseModel): Project, on_delete=models.CASCADE, related_name="project_%(class)s" ) workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_%(class)s" ) class Meta: @@ -235,10 +240,7 @@ class ProjectMember(ProjectBaseModel): # TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): workspace = models.ForeignKey( - "db.Workspace", - models.CASCADE, - related_name="project_identifiers", - null=True, + "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True ) project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" @@ -260,26 +262,6 @@ class ProjectIdentifier(AuditModel): ordering = ("-created_at",) -# DEPRECATED TODO: - Remove in next release -class ProjectFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="project_favorites", - ) - - class Meta: - unique_together = ["project", "user"] - verbose_name = "Project Favorite" - verbose_name_plural = "Project Favorites" - db_table = "project_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user of the project""" - return f"{self.user.email} <{self.project.name}>" - - def get_anchor(): return uuid4().hex @@ -302,11 +284,8 @@ class ProjectDeployBoard(ProjectBaseModel): ) comments = models.BooleanField(default=False) reactions = models.BooleanField(default=False) - inbox = models.ForeignKey( - "db.Inbox", - related_name="bord_inbox", - on_delete=models.SET_NULL, - null=True, + intake = models.ForeignKey( + "db.Intake", related_name="board_intake", on_delete=models.SET_NULL, null=True ) votes = models.BooleanField(default=False) views = models.JSONField(default=get_default_views) diff --git a/apiserver/plane/db/models/recent_visit.py b/apiserver/plane/db/models/recent_visit.py index 4696ead46..14737eba5 100644 --- a/apiserver/plane/db/models/recent_visit.py +++ b/apiserver/plane/db/models/recent_visit.py @@ -17,10 +17,7 @@ class EntityNameEnum(models.TextChoices): class UserRecentVisit(WorkspaceBaseModel): entity_identifier = models.UUIDField(null=True) - entity_name = models.CharField( - max_length=30, - choices=EntityNameEnum.choices, - ) + entity_name = models.CharField(max_length=30, choices=EntityNameEnum.choices) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apiserver/plane/db/models/session.py b/apiserver/plane/db/models/session.py index 95e8e0b7d..3b35ebc70 100644 --- a/apiserver/plane/db/models/session.py +++ b/apiserver/plane/db/models/session.py @@ -11,19 +11,9 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits class Session(AbstractBaseSession): - device_info = models.JSONField( - null=True, - blank=True, - default=None, - ) - session_key = models.CharField( - max_length=128, - primary_key=True, - ) - user_id = models.CharField( - null=True, - max_length=50, - ) + device_info = models.JSONField(null=True, blank=True, default=None) + session_key = models.CharField(max_length=128, primary_key=True) + user_id = models.CharField(null=True, max_length=50) @classmethod def get_session_store_class(cls): @@ -34,7 +24,6 @@ class Session(AbstractBaseSession): class SessionStore(DBSessionStore): - @classmethod def get_model_class(cls): return Session @@ -59,7 +48,5 @@ class SessionStore(DBSessionStore): # Save the device info device_info = data.get("device_info") - obj.device_info = ( - device_info if isinstance(device_info, dict) else None - ) + obj.device_info = device_info if isinstance(device_info, dict) else None return obj diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py index 2a21c55fd..9a85a320d 100644 --- a/apiserver/plane/db/models/social_connection.py +++ b/apiserver/plane/db/models/social_connection.py @@ -10,7 +10,12 @@ from .base import BaseModel class SocialLoginConnection(BaseModel): medium = models.CharField( max_length=20, - choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("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/state.py b/apiserver/plane/db/models/state.py index c661a4d61..3478d70d2 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -9,9 +9,7 @@ from .project import ProjectBaseModel class State(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="State Name") - description = models.TextField( - verbose_name="State Description", blank=True - ) + description = models.TextField(verbose_name="State Description", blank=True) color = models.CharField(max_length=255, verbose_name="State Color") slug = models.SlugField(max_length=100, blank=True) sequence = models.FloatField(default=65535) @@ -22,7 +20,7 @@ class State(ProjectBaseModel): ("started", "Started"), ("completed", "Completed"), ("cancelled", "Cancelled"), - ("triage", "Triage") + ("triage", "Triage"), ), default="backlog", max_length=20, diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 2a88df8b6..34a86a251 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -4,11 +4,7 @@ import string import uuid import pytz -from django.contrib.auth.models import ( - AbstractBaseUser, - PermissionsMixin, - UserManager, -) +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager # Django imports from django.db import models @@ -17,6 +13,7 @@ from django.dispatch import receiver from django.utils import timezone # Module imports +from plane.db.models import FileAsset from ..mixins import TimeAuditModel @@ -31,36 +28,40 @@ def get_default_onboarding(): class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) username = models.CharField(max_length=128, unique=True) # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) - email = models.CharField( - max_length=255, null=True, blank=True, unique=True - ) + email = models.CharField(max_length=255, null=True, blank=True, unique=True) # identity display_name = models.CharField(max_length=255, default="") first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) + # avatar avatar = models.TextField(blank=True) + avatar_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_avatar", + ) + # cover image cover_image = models.URLField(blank=True, null=True, max_length=800) + cover_image_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_cover_image", + ) # tracking metrics - date_joined = models.DateTimeField( - auto_now_add=True, verbose_name="Created At" - ) - created_at = models.DateTimeField( - auto_now_add=True, verbose_name="Created At" - ) - updated_at = models.DateTimeField( - auto_now=True, verbose_name="Last Modified At" - ) + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") last_location = models.CharField(max_length=255, blank=True) created_location = models.CharField(max_length=255, blank=True) @@ -81,15 +82,15 @@ class User(AbstractBaseUser, PermissionsMixin): last_logout_time = models.DateTimeField(null=True) last_login_ip = models.CharField(max_length=255, blank=True) last_logout_ip = models.CharField(max_length=255, blank=True) - last_login_medium = models.CharField( - max_length=20, - default="email", - ) + last_login_medium = models.CharField(max_length=20, default="email") last_login_uagent = models.TextField(blank=True) token_updated_at = models.DateTimeField(null=True) # my_issues_prop = models.JSONField(null=True) is_bot = models.BooleanField(default=False) + bot_type = models.CharField( + max_length=30, verbose_name="Bot Type", blank=True, null=True + ) # timezone USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) @@ -111,6 +112,28 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): return f"{self.username} <{self.email}>" + @property + def avatar_url(self): + # Return the logo asset url if it exists + if self.avatar_asset: + return self.avatar_asset.asset_url + + # Return the logo url if it exists + if self.avatar: + return self.avatar + return None + + @property + def cover_image_url(self): + # Return the logo asset url if it exists + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return the logo url if it exists + if self.cover_image: + return self.cover_image + return None + def save(self, *args, **kwargs): self.email = self.email.lower().strip() self.mobile_number = self.mobile_number @@ -123,9 +146,7 @@ class User(AbstractBaseUser, PermissionsMixin): self.display_name = ( self.email.split("@")[0] if len(self.email.split("@")) - else "".join( - random.choice(string.ascii_letters) for _ in range(6) - ) + else "".join(random.choice(string.ascii_letters) for _ in range(6)) ) if self.is_superuser: @@ -136,11 +157,7 @@ class User(AbstractBaseUser, PermissionsMixin): class Profile(TimeAuditModel): id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) # User user = models.OneToOneField( @@ -170,20 +187,20 @@ class Profile(TimeAuditModel): class Account(TimeAuditModel): + PROVIDER_CHOICES = ( + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ) + id = models.UUIDField( - default=uuid.uuid4, - unique=True, - editable=False, - db_index=True, - primary_key=True, + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) user = models.ForeignKey( "db.User", on_delete=models.CASCADE, related_name="accounts" ) provider_account_id = models.CharField(max_length=255) - provider = models.CharField( - choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")), - ) + provider = models.CharField(choices=PROVIDER_CHOICES) access_token = models.TextField() access_token_expired_at = models.DateTimeField(null=True) refresh_token = models.TextField(null=True, blank=True) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 2a5bae569..c9182acce 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -3,8 +3,6 @@ from django.conf import settings from django.db import models # Module import -from .base import BaseModel -from .project import ProjectBaseModel from .workspace import WorkspaceBaseModel from plane.utils.issue_filters import issue_filters @@ -52,41 +50,6 @@ def get_default_display_properties(): "updated_on": True, } -# DEPRECATED TODO: - Remove in next release -class GlobalView(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="global_views" - ) - name = models.CharField(max_length=255, verbose_name="View Name") - description = models.TextField(verbose_name="View Description", blank=True) - query = models.JSONField(verbose_name="View Query") - access = models.PositiveSmallIntegerField( - default=1, choices=((0, "Private"), (1, "Public")) - ) - query_data = models.JSONField(default=dict) - sort_order = models.FloatField(default=65535) - logo_props = models.JSONField(default=dict) - - class Meta: - verbose_name = "Global View" - verbose_name_plural = "Global Views" - db_table = "global_views" - ordering = ("-created_at",) - - def save(self, *args, **kwargs): - if self._state.adding: - largest_sort_order = GlobalView.objects.filter( - workspace=self.workspace - ).aggregate(largest=models.Max("sort_order"))["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 - - super(GlobalView, self).save(*args, **kwargs) - - def __str__(self): - """Return name of the View""" - return f"{self.name} <{self.workspace.name}>" - class IssueView(WorkspaceBaseModel): name = models.CharField(max_length=255, verbose_name="View Name") @@ -94,22 +57,17 @@ class IssueView(WorkspaceBaseModel): query = models.JSONField(verbose_name="View Query") filters = models.JSONField(default=dict) display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField( - default=get_default_display_properties - ) + display_properties = models.JSONField(default=get_default_display_properties) access = models.PositiveSmallIntegerField( default=1, choices=((0, "Private"), (1, "Public")) ) 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", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views" ) is_locked = models.BooleanField(default=False) - class Meta: verbose_name = "Issue View" verbose_name_plural = "Issue Views" @@ -118,9 +76,7 @@ class IssueView(WorkspaceBaseModel): def save(self, *args, **kwargs): query_params = self.filters - self.query = ( - issue_filters(query_params, "POST") if query_params else {} - ) + self.query = issue_filters(query_params, "POST") if query_params else {} if self._state.adding: if self.project: @@ -139,26 +95,3 @@ class IssueView(WorkspaceBaseModel): def __str__(self): """Return name of the View""" return f"{self.name} <{self.project.name}>" - - -# DEPRECATED TODO: - Remove in next release -class IssueViewFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="user_view_favorites", - ) - view = models.ForeignKey( - "db.IssueView", on_delete=models.CASCADE, related_name="view_favorites" - ) - - class Meta: - unique_together = ["view", "user"] - verbose_name = "View Favorite" - verbose_name_plural = "View Favorites" - db_table = "view_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the view""" - return f"{self.user.email} <{self.view.name}>" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index fbe74d03a..92d45a058 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -17,9 +17,7 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) if parsed_url.scheme not in ["http", "https"]: - raise ValidationError( - "Invalid schema. Only HTTP and HTTPS are allowed." - ) + raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") def validate_domain(value): @@ -36,10 +34,7 @@ class Webhook(BaseModel): related_name="workspace_webhooks", ) url = models.URLField( - validators=[ - validate_schema, - validate_domain, - ] + validators=[validate_schema, validate_domain], max_length=1024 ) is_active = models.BooleanField(default=True) secret_key = models.CharField(max_length=255, default=generate_token) @@ -48,6 +43,7 @@ class Webhook(BaseModel): module = models.BooleanField(default=False) cycle = models.BooleanField(default=False) issue_comment = models.BooleanField(default=False) + is_internal = models.BooleanField(default=False) def __str__(self): return f"{self.workspace.slug} {self.url}" @@ -65,9 +61,7 @@ class WebhookLog(BaseModel): "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" ) # Associated webhook - webhook = models.ForeignKey( - Webhook, on_delete=models.CASCADE, related_name="logs" - ) + webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") # Basic request details event_type = models.CharField(max_length=255, blank=True, null=True) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 50dac6096..df1f26d3f 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,3 +1,6 @@ +# Python imports +import pytz + # Django imports from django.conf import settings from django.core.exceptions import ValidationError @@ -7,11 +10,7 @@ from django.db import models from .base import BaseModel from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS -ROLE_CHOICES = ( - (20, "Admin"), - (15, "Member"), - (5, "Guest"), -) +ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) def get_default_props(): @@ -78,7 +77,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", - }, + } } @@ -98,7 +97,7 @@ def get_default_display_properties(): "state": True, "sub_issue_count": True, "updated_on": True, - }, + } } @@ -117,27 +116,45 @@ def slug_validator(value): class Workspace(BaseModel): + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + name = models.CharField(max_length=80, verbose_name="Workspace Name") - logo = models.URLField(verbose_name="Logo", blank=True, null=True) + logo = models.TextField(verbose_name="Logo", blank=True, null=True) + logo_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + related_name="workspace_logo", + blank=True, + null=True, + ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owner_workspace", ) slug = models.SlugField( - max_length=48, - db_index=True, - unique=True, - validators=[ - slug_validator, - ], + max_length=48, db_index=True, unique=True, validators=[slug_validator] ) organization_size = models.CharField(max_length=20, blank=True, null=True) + timezone = models.CharField( + max_length=255, default="UTC", choices=TIMEZONE_CHOICES + ) def __str__(self): """Return name of the Workspace""" return self.name + @property + def logo_url(self): + # Return the logo asset url if it exists + if self.logo_asset: + return self.logo_asset.asset_url + + # Return the logo url if it exists + if self.logo: + return self.logo + return None + class Meta: verbose_name = "Workspace" verbose_name_plural = "Workspaces" @@ -236,13 +253,6 @@ class WorkspaceMemberInvite(BaseModel): class Team(BaseModel): name = models.CharField(max_length=255, verbose_name="Team Name") description = models.TextField(verbose_name="Team Description", blank=True) - members = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="members", - through="TeamMember", - through_fields=("team", "member"), - ) workspace = models.ForeignKey( Workspace, on_delete=models.CASCADE, related_name="workspace_team" ) @@ -267,37 +277,6 @@ class Team(BaseModel): ordering = ("-created_at",) -class TeamMember(BaseModel): - workspace = models.ForeignKey( - Workspace, on_delete=models.CASCADE, related_name="team_member" - ) - team = models.ForeignKey( - Team, on_delete=models.CASCADE, related_name="team_member" - ) - member = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="team_member", - ) - - def __str__(self): - return self.team.name - - class Meta: - unique_together = ["team", "member", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["team", "member"], - condition=models.Q(deleted_at__isnull=True), - name="team_member_unique_team_member_when_deleted_at_null", - ) - ] - verbose_name = "Team Member" - verbose_name_plural = "Team Members" - db_table = "team_members" - ordering = ("-created_at",) - - class WorkspaceTheme(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="themes" diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py index 9ee85404b..848de4d7b 100644 --- a/apiserver/plane/license/api/permissions/instance.py +++ b/apiserver/plane/license/api/permissions/instance.py @@ -12,7 +12,5 @@ class InstanceAdminPermission(BasePermission): instance = Instance.objects.first() return InstanceAdmin.objects.filter( - role__gte=15, - instance=instance, - user=request.user, + role__gte=15, instance=instance, user=request.user ).exists() diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index 7b9cb676f..48ecd4536 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -1,6 +1,5 @@ -from .instance import ( - InstanceSerializer, -) +from .instance import InstanceSerializer from .configuration import InstanceConfigurationSerializer from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer +from .workspace import WorkspaceSerializer \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py index 848e94ef7..4df6901ca 100644 --- a/apiserver/plane/license/api/serializers/admin.py +++ b/apiserver/plane/license/api/serializers/admin.py @@ -11,6 +11,7 @@ class InstanceAdminMeSerializer(BaseSerializer): fields = [ "id", "avatar", + "avatar_url", "cover_image", "date_joined", "display_name", @@ -34,8 +35,4 @@ class InstanceAdminSerializer(BaseSerializer): class Meta: model = InstanceAdmin fields = "__all__" - read_only_fields = [ - "id", - "instance", - "user", - ] + read_only_fields = ["id", "instance", "user"] diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 606bb643f..49c5194c8 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -11,13 +11,5 @@ class InstanceSerializer(BaseSerializer): class Meta: model = Instance - exclude = [ - "license_key", - "user_count" - ] - read_only_fields = [ - "id", - "email", - "last_checked_at", - "is_setup_done", - ] + fields = "__all__" + read_only_fields = ["id", "email", "last_checked_at", "is_setup_done"] diff --git a/apiserver/plane/license/api/serializers/user.py b/apiserver/plane/license/api/serializers/user.py new file mode 100644 index 000000000..8935a882f --- /dev/null +++ b/apiserver/plane/license/api/serializers/user.py @@ -0,0 +1,6 @@ +from .base import BaseSerializer +from plane.db.models import User +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = ["id", "email", "first_name", "last_name",] diff --git a/apiserver/plane/license/api/serializers/workspace.py b/apiserver/plane/license/api/serializers/workspace.py new file mode 100644 index 000000000..75dd938e4 --- /dev/null +++ b/apiserver/plane/license/api/serializers/workspace.py @@ -0,0 +1,37 @@ +# Third Party Imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Workspace +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class WorkspaceSerializer(BaseSerializer): + owner = UserLiteSerializer(read_only=True) + logo_url = serializers.CharField(read_only=True) + total_projects = serializers.IntegerField(read_only=True) + total_members = serializers.IntegerField(read_only=True) + + def validate_slug(self, value): + # Check if the slug is restricted + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + # Check uniqueness case-insensitively + if Workspace.objects.filter(slug__iexact=value).exists(): + raise serializers.ValidationError("Slug is already in use") + return value + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + "logo_url", + ] diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index b10702b8a..d57ebf52c 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -1,13 +1,7 @@ -from .instance import ( - InstanceEndpoint, - SignUpScreenVisitedEndpoint, -) +from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint -from .configuration import ( - EmailCredentialCheckEndpoint, - InstanceConfigurationEndpoint, -) +from .configuration import EmailCredentialCheckEndpoint, InstanceConfigurationEndpoint from .admin import ( @@ -18,3 +12,7 @@ from .admin import ( InstanceAdminSignOutEndpoint, InstanceAdminUserSessionEndpoint, ) + +from .changelog import ChangeLogEndpoint + +from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 5d93aba49..10c6df5c3 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -36,9 +36,7 @@ from plane.authentication.adapter.error import ( class InstanceAdminEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] + permission_classes = [InstanceAdminPermission] @invalidate_cache(path="/api/instances/", user=False) # Create an instance admin @@ -48,8 +46,7 @@ class InstanceAdminEndpoint(BaseAPIView): if not email: return Response( - {"error": "Email is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST ) instance = Instance.objects.first() @@ -63,9 +60,7 @@ class InstanceAdminEndpoint(BaseAPIView): user = User.objects.get(email=email) instance_admin = InstanceAdmin.objects.create( - instance=instance, - user=user, - role=role, + instance=instance, user=user, role=role ) serializer = InstanceAdminSerializer(instance_admin) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -90,9 +85,7 @@ class InstanceAdminEndpoint(BaseAPIView): class InstanceAdminSignUpEndpoint(View): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] @invalidate_cache(path="/api/instances/", user=False) def post(self, request): @@ -100,9 +93,7 @@ class InstanceAdminSignUpEndpoint(View): instance = Instance.objects.first() if instance is None: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) url = urljoin( @@ -178,9 +169,7 @@ class InstanceAdminSignUpEndpoint(View): # Existing user if User.objects.filter(email=email).exists(): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "ADMIN_USER_ALREADY_EXIST" - ], + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_ALREADY_EXIST"], error_message="ADMIN_USER_ALREADY_EXIST", payload={ "email": email, @@ -196,13 +185,10 @@ class InstanceAdminSignUpEndpoint(View): ) return HttpResponseRedirect(url) else: - results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_ADMIN_PASSWORD" - ], + error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"], error_message="INVALID_ADMIN_PASSWORD", payload={ "email": email, @@ -237,10 +223,7 @@ class InstanceAdminSignUpEndpoint(View): user.save() # Register the user as an instance admin - _ = InstanceAdmin.objects.create( - user=user, - instance=instance, - ) + _ = InstanceAdmin.objects.create(user=user, instance=instance) # Make the setup flag True instance.is_setup_done = True instance.instance_name = company_name @@ -254,9 +237,7 @@ class InstanceAdminSignUpEndpoint(View): class InstanceAdminSignInEndpoint(View): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] @invalidate_cache(path="/api/instances/", user=False) def post(self, request): @@ -264,9 +245,7 @@ class InstanceAdminSignInEndpoint(View): instance = Instance.objects.first() if instance is None: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INSTANCE_NOT_CONFIGURED" - ], + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], error_message="INSTANCE_NOT_CONFIGURED", ) url = urljoin( @@ -282,13 +261,9 @@ class InstanceAdminSignInEndpoint(View): # return error if the email and password is not present if not email or not password: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_ADMIN_EMAIL_PASSWORD" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_ADMIN_EMAIL_PASSWORD"], error_message="REQUIRED_ADMIN_EMAIL_PASSWORD", - payload={ - "email": email, - }, + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -304,9 +279,7 @@ class InstanceAdminSignInEndpoint(View): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_EMAIL"], error_message="INVALID_ADMIN_EMAIL", - payload={ - "email": email, - }, + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -320,9 +293,7 @@ class InstanceAdminSignInEndpoint(View): # is_active if not user.is_active: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "ADMIN_USER_DEACTIVATED" - ], + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"], error_message="ADMIN_USER_DEACTIVATED", ) url = urljoin( @@ -334,13 +305,9 @@ class InstanceAdminSignInEndpoint(View): # Error out if the user is not present if not user: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "ADMIN_USER_DOES_NOT_EXIST" - ], + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"], error_message="ADMIN_USER_DOES_NOT_EXIST", - payload={ - "email": email, - }, + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -351,13 +318,9 @@ class InstanceAdminSignInEndpoint(View): # Check password of the user if not user.check_password(password): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "ADMIN_AUTHENTICATION_FAILED" - ], + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"], error_message="ADMIN_AUTHENTICATION_FAILED", - payload={ - "email": email, - }, + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -368,13 +331,9 @@ class InstanceAdminSignInEndpoint(View): # Check if the user is an instance admin if not InstanceAdmin.objects.filter(instance=instance, user=user): exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "ADMIN_AUTHENTICATION_FAILED" - ], + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"], error_message="ADMIN_AUTHENTICATION_FAILED", - payload={ - "email": email, - }, + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -397,24 +356,15 @@ class InstanceAdminSignInEndpoint(View): class InstanceAdminUserMeEndpoint(BaseAPIView): - - permission_classes = [ - InstanceAdminPermission, - ] + permission_classes = [InstanceAdminPermission] def get(self, request): serializer = InstanceAdminMeSerializer(request.user) - return Response( - serializer.data, - status=status.HTTP_200_OK, - ) + return Response(serializer.data, status=status.HTTP_200_OK) class InstanceAdminUserSessionEndpoint(BaseAPIView): - - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request): if ( @@ -424,21 +374,13 @@ class InstanceAdminUserSessionEndpoint(BaseAPIView): serializer = InstanceAdminMeSerializer(request.user) data = {"is_authenticated": True} data["user"] = serializer.data - return Response( - data, - status=status.HTTP_200_OK, - ) + return Response(data, status=status.HTTP_200_OK) else: - return Response( - {"is_authenticated": False}, status=status.HTTP_200_OK - ) + return Response({"is_authenticated": False}, status=status.HTTP_200_OK) class InstanceAdminSignOutEndpoint(View): - - permission_classes = [ - InstanceAdminPermission, - ] + permission_classes = [InstanceAdminPermission] def post(self, request): # Get user @@ -452,6 +394,4 @@ class InstanceAdminSignOutEndpoint(View): url = urljoin(base_host(request=request, is_admin=True)) return HttpResponseRedirect(url) except Exception: - return HttpResponseRedirect( - base_host(request=request, is_admin=True) - ) + return HttpResponseRedirect(base_host(request=request, is_admin=True)) diff --git a/apiserver/plane/license/api/views/base.py b/apiserver/plane/license/api/views/base.py index 7e367f941..05b42b801 100644 --- a/apiserver/plane/license/api/views/base.py +++ b/apiserver/plane/license/api/views/base.py @@ -36,18 +36,11 @@ class TimezoneMixin: class BaseAPIView(TimezoneMixin, APIView, BasePaginator): - permission_classes = [ - InstanceAdminPermission, - ] + permission_classes = [InstanceAdminPermission] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) + filter_backends = (DjangoFilterBackend, SearchFilter) - authentication_classes = [ - BaseSessionAuthentication, - ] + authentication_classes = [BaseSessionAuthentication] filterset_fields = [] @@ -116,17 +109,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def fields(self): fields = [ - field - for field in self.request.GET.get("fields", "").split(",") - if field + field for field in self.request.GET.get("fields", "").split(",") if field ] return fields if fields else None @property def expand(self): expand = [ - expand - for expand in self.request.GET.get("expand", "").split(",") - if expand + expand for expand in self.request.GET.get("expand", "").split(",") if expand ] return expand if expand else None diff --git a/apiserver/plane/license/api/views/changelog.py b/apiserver/plane/license/api/views/changelog.py new file mode 100644 index 000000000..52583a35f --- /dev/null +++ b/apiserver/plane/license/api/views/changelog.py @@ -0,0 +1,33 @@ +# Python imports +import requests + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# plane imports +from .base import BaseAPIView + + +class ChangeLogEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def fetch_change_logs(self): + response = requests.get(settings.INSTANCE_CHANGELOG_URL) + response.raise_for_status() + return response.json() + + def get(self, request): + # Fetch the changelog + if settings.INSTANCE_CHANGELOG_URL: + data = self.fetch_change_logs() + return Response(data, status=status.HTTP_200_OK) + else: + return Response( + {"error": "could not fetch changelog please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/license/api/views/configuration.py b/apiserver/plane/license/api/views/configuration.py index 06f53b753..594a899eb 100644 --- a/apiserver/plane/license/api/views/configuration.py +++ b/apiserver/plane/license/api/views/configuration.py @@ -8,11 +8,7 @@ from smtplib import ( ) # Django imports -from django.core.mail import ( - BadHeaderError, - EmailMultiAlternatives, - get_connection, -) +from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection # Third party imports from rest_framework import status @@ -25,22 +21,16 @@ from plane.license.models import InstanceConfiguration from plane.license.api.serializers import InstanceConfigurationSerializer from plane.license.utils.encryption import encrypt_data from plane.utils.cache import cache_response, invalidate_cache -from plane.license.utils.instance_value import ( - get_email_configuration, -) +from plane.license.utils.instance_value import get_email_configuration class InstanceConfigurationEndpoint(BaseAPIView): - permission_classes = [ - InstanceAdminPermission, - ] + permission_classes = [InstanceAdminPermission] @cache_response(60 * 60 * 2, user=False) def get(self, request): instance_configurations = InstanceConfiguration.objects.all() - serializer = InstanceConfigurationSerializer( - instance_configurations, many=True - ) + serializer = InstanceConfigurationSerializer(instance_configurations, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @invalidate_cache(path="/api/instances/configurations/", user=False) @@ -68,7 +58,6 @@ class InstanceConfigurationEndpoint(BaseAPIView): class EmailCredentialCheckEndpoint(BaseAPIView): - def post(self, request): receiver_email = request.data.get("receiver_email", False) if not receiver_email: @@ -98,9 +87,7 @@ class EmailCredentialCheckEndpoint(BaseAPIView): ) # Prepare email details subject = "Email Notification from Plane" - message = ( - "This is a sample email notification sent from Plane application." - ) + message = "This is a sample email notification sent from Plane application." # Send the email try: msg = EmailMultiAlternatives( @@ -112,13 +99,11 @@ class EmailCredentialCheckEndpoint(BaseAPIView): ) msg.send(fail_silently=False) return Response( - {"message": "Email successfully sent."}, - status=status.HTTP_200_OK, + {"message": "Email successfully sent."}, status=status.HTTP_200_OK ) except BadHeaderError: return Response( - {"error": "Invalid email header."}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Invalid email header."}, status=status.HTTP_400_BAD_REQUEST ) except SMTPAuthenticationError: return Response( @@ -147,9 +132,7 @@ class EmailCredentialCheckEndpoint(BaseAPIView): ) except TimeoutError: return Response( - { - "error": "Timeout error while trying to connect to the SMTP server." - }, + {"error": "Timeout error while trying to connect to the SMTP server."}, status=status.HTTP_400_BAD_REQUEST, ) except ConnectionError: @@ -161,8 +144,6 @@ class EmailCredentialCheckEndpoint(BaseAPIView): ) except Exception: return Response( - { - "error": "Could not send email. Please check your configuration" - }, + {"error": "Could not send email. Please check your configuration"}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 9aac3fb18..0e2b64fc9 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -12,16 +12,10 @@ from rest_framework.response import Response # Module imports from plane.app.views import BaseAPIView from plane.db.models import Workspace -from plane.license.api.permissions import ( - InstanceAdminPermission, -) -from plane.license.api.serializers import ( - InstanceSerializer, -) +from plane.license.api.permissions import InstanceAdminPermission +from plane.license.api.serializers import InstanceSerializer from plane.license.models import Instance -from plane.license.utils.instance_value import ( - get_configuration_value, -) +from plane.license.utils.instance_value import get_configuration_value from plane.utils.cache import cache_response, invalidate_cache from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control @@ -30,12 +24,8 @@ from django.views.decorators.cache import cache_control class InstanceEndpoint(BaseAPIView): def get_permissions(self): if self.request.method == "PATCH": - return [ - InstanceAdminPermission(), - ] - return [ - AllowAny(), - ] + return [InstanceAdminPermission()] + return [AllowAny()] @cache_response(60 * 60 * 2, user=False) @method_decorator(cache_control(private=True, max_age=12)) @@ -55,6 +45,7 @@ class InstanceEndpoint(BaseAPIView): # Get all the configuration ( ENABLE_SIGNUP, + DISABLE_WORKSPACE_CREATION, IS_GOOGLE_ENABLED, IS_GITHUB_ENABLED, GITHUB_APP_NAME, @@ -75,6 +66,10 @@ class InstanceEndpoint(BaseAPIView): "key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "0"), }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + }, { "key": "IS_GOOGLE_ENABLED", "default": os.environ.get("IS_GOOGLE_ENABLED", "0"), @@ -91,10 +86,7 @@ class InstanceEndpoint(BaseAPIView): "key": "IS_GITLAB_ENABLED", "default": os.environ.get("IS_GITLAB_ENABLED", "0"), }, - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST", ""), - }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -138,6 +130,7 @@ class InstanceEndpoint(BaseAPIView): data = {} # Authentication data["enable_signup"] = ENABLE_SIGNUP == "1" + data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1" data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" @@ -161,9 +154,7 @@ class InstanceEndpoint(BaseAPIView): data["has_openai_configured"] = bool(OPENAI_API_KEY) # File size settings - data["file_size_limit"] = float( - os.environ.get("FILE_SIZE_LIMIT", 5242880) - ) + data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # is smtp configured data["is_smtp_configured"] = bool(EMAIL_HOST) @@ -177,6 +168,8 @@ class InstanceEndpoint(BaseAPIView): data["space_base_url"] = settings.SPACE_BASE_URL data["app_base_url"] = settings.APP_BASE_URL + data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL + instance_data = serializer.data instance_data["workspaces_exist"] = Workspace.objects.count() >= 1 @@ -187,9 +180,7 @@ class InstanceEndpoint(BaseAPIView): def patch(self, request): # Get the instance instance = Instance.objects.first() - serializer = InstanceSerializer( - instance, data=request.data, partial=True - ) + serializer = InstanceSerializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -197,9 +188,7 @@ class InstanceEndpoint(BaseAPIView): class SignUpScreenVisitedEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] @invalidate_cache(path="/api/instances/", user=False) def post(self, request): diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py new file mode 100644 index 000000000..14118d85b --- /dev/null +++ b/apiserver/plane/license/api/views/workspace.py @@ -0,0 +1,115 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from django.db import IntegrityError +from django.db.models import OuterRef, Func, F + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.db.models import Workspace, WorkspaceMember, Project +from plane.license.api.serializers import WorkspaceSerializer +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def get(self, request): + slug = request.GET.get("slug", False) + + if not slug or slug == "": + return Response( + {"error": "Workspace Slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = ( + Workspace.objects.filter(slug__iexact=slug).exists() + or slug in RESTRICTED_WORKSPACE_SLUGS + ) + return Response({"status": not workspace}, status=status.HTTP_200_OK) + + +class InstanceWorkSpaceEndpoint(BaseAPIView): + model = Workspace + serializer_class = WorkspaceSerializer + permission_classes = [InstanceAdminPermission] + + def get(self, request): + project_count = ( + Project.objects.filter(workspace_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + member_count = ( + WorkspaceMember.objects.filter( + workspace=OuterRef("id"), member__is_bot=False, is_active=True + ).select_related("owner") + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + workspaces = Workspace.objects.annotate( + total_projects=project_count, + total_members=member_count, + ) + + # Add search functionality + search = request.query_params.get("search", None) + if search: + workspaces = workspaces.filter(name__icontains=search) + + return self.paginate( + request=request, + queryset=workspaces, + on_results=lambda results: WorkspaceSerializer( + results, many=True, + ).data, + max_per_page=10, + default_per_page=10, + ) + + def post(self, request): + try: + serializer = WorkspaceSerializer (data=request.data) + + slug = request.data.get("slug", False) + name = request.data.get("name", False) + + if not name or not slug: + return Response( + {"error": "Both name and slug are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if len(name) > 80 or len(slug) > 48: + return Response( + {"error": "The maximum length for name is 80 and for slug is 48"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(owner=request.user) + # Create Workspace member + _ = WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=request.data.get("company_role", ""), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_410_GONE, + ) diff --git a/apiserver/plane/license/bgtasks/tracer.py b/apiserver/plane/license/bgtasks/tracer.py index 26efb45ed..47e74c83a 100644 --- a/apiserver/plane/license/bgtasks/tracer.py +++ b/apiserver/plane/license/bgtasks/tracer.py @@ -16,75 +16,55 @@ from plane.db.models import ( Page, WorkspaceMember, ) +from plane.utils.telemetry import init_tracer, shutdown_tracer @shared_task def instance_traces(): - # Get the tracer - tracer = trace.get_tracer(__name__) + try: + init_tracer() + # Check if the instance is registered + instance = Instance.objects.first() - # Check if the instance is registered - instance = Instance.objects.first() + # If instance is None then return + if instance is None: + return - # If instance is None then return - if instance is None: - return + if instance.is_telemetry_enabled: + # Get the tracer + tracer = trace.get_tracer(__name__) + # Instance details + with tracer.start_as_current_span("instance_details") as span: + # Count of all models + workspace_count = Workspace.objects.count() + user_count = User.objects.count() + project_count = Project.objects.count() + issue_count = Issue.objects.count() + module_count = Module.objects.count() + cycle_count = Cycle.objects.count() + cycle_issue_count = CycleIssue.objects.count() + module_issue_count = ModuleIssue.objects.count() + page_count = Page.objects.count() - if instance.is_telemetry_enabled: - # Instance details - with tracer.start_as_current_span("instance_details") as span: - # Count of all models - workspace_count = Workspace.objects.count() - user_count = User.objects.count() - project_count = Project.objects.count() - issue_count = Issue.objects.count() - module_count = Module.objects.count() - cycle_count = Cycle.objects.count() - cycle_issue_count = CycleIssue.objects.count() - module_issue_count = ModuleIssue.objects.count() - page_count = Page.objects.count() - - # Set span attributes - span.set_attribute("instance_id", instance.instance_id) - span.set_attribute("instance_name", instance.instance_name) - span.set_attribute("current_version", instance.current_version) - span.set_attribute("latest_version", instance.latest_version) - span.set_attribute( - "is_telemetry_enabled", instance.is_telemetry_enabled - ) - span.set_attribute("user_count", user_count) - span.set_attribute("workspace_count", workspace_count) - span.set_attribute("project_count", project_count) - span.set_attribute("issue_count", issue_count) - span.set_attribute("module_count", module_count) - span.set_attribute("cycle_count", cycle_count) - span.set_attribute("cycle_issue_count", cycle_issue_count) - span.set_attribute("module_issue_count", module_issue_count) - span.set_attribute("page_count", page_count) - - # Workspace details - for workspace in Workspace.objects.all(): - # Count of all models - project_count = Project.objects.filter(workspace=workspace).count() - issue_count = Issue.objects.filter(workspace=workspace).count() - module_count = Module.objects.filter(workspace=workspace).count() - cycle_count = Cycle.objects.filter(workspace=workspace).count() - cycle_issue_count = CycleIssue.objects.filter( - workspace=workspace - ).count() - module_issue_count = ModuleIssue.objects.filter( - workspace=workspace - ).count() - page_count = Page.objects.filter(workspace=workspace).count() - member_count = WorkspaceMember.objects.filter( - workspace=workspace - ).count() - - # Set span attributes - with tracer.start_as_current_span("workspace_details") as span: + # Set span attributes span.set_attribute("instance_id", instance.instance_id) - span.set_attribute("workspace_id", str(workspace.id)) - span.set_attribute("workspace_slug", workspace.slug) + span.set_attribute("instance_name", instance.instance_name) + span.set_attribute("current_version", instance.current_version) + span.set_attribute("latest_version", instance.latest_version) + span.set_attribute( + "is_telemetry_enabled", instance.is_telemetry_enabled + ) + span.set_attribute("is_support_required", instance.is_support_required) + span.set_attribute("is_setup_done", instance.is_setup_done) + span.set_attribute( + "is_signup_screen_visited", instance.is_signup_screen_visited + ) + span.set_attribute("is_verified", instance.is_verified) + span.set_attribute("edition", instance.edition) + span.set_attribute("domain", instance.domain) + span.set_attribute("is_test", instance.is_test) + span.set_attribute("user_count", user_count) + span.set_attribute("workspace_count", workspace_count) span.set_attribute("project_count", project_count) span.set_attribute("issue_count", issue_count) span.set_attribute("module_count", module_count) @@ -92,6 +72,40 @@ def instance_traces(): span.set_attribute("cycle_issue_count", cycle_issue_count) span.set_attribute("module_issue_count", module_issue_count) span.set_attribute("page_count", page_count) - span.set_attribute("member_count", member_count) - return + # Workspace details + for workspace in Workspace.objects.all(): + # Count of all models + project_count = Project.objects.filter(workspace=workspace).count() + issue_count = Issue.objects.filter(workspace=workspace).count() + module_count = Module.objects.filter(workspace=workspace).count() + cycle_count = Cycle.objects.filter(workspace=workspace).count() + cycle_issue_count = CycleIssue.objects.filter( + workspace=workspace + ).count() + module_issue_count = ModuleIssue.objects.filter( + workspace=workspace + ).count() + page_count = Page.objects.filter(workspace=workspace).count() + member_count = WorkspaceMember.objects.filter( + workspace=workspace + ).count() + + # Set span attributes + with tracer.start_as_current_span("workspace_details") as span: + span.set_attribute("instance_id", instance.instance_id) + span.set_attribute("workspace_id", str(workspace.id)) + span.set_attribute("workspace_slug", workspace.slug) + span.set_attribute("project_count", project_count) + span.set_attribute("issue_count", issue_count) + span.set_attribute("module_count", module_count) + span.set_attribute("cycle_count", cycle_count) + span.set_attribute("cycle_issue_count", cycle_issue_count) + span.set_attribute("module_issue_count", module_issue_count) + span.set_attribute("page_count", page_count) + span.set_attribute("member_count", member_count) + + return + finally: + # Shutdown the tracer + shutdown_tracer() diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index ba6a57d4b..548c9c77e 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -29,6 +29,12 @@ class Command(BaseCommand): "category": "AUTHENTICATION", "is_encrypted": False, }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + "category": "WORKSPACE_MANAGEMENT", + "is_encrypted": False, + }, { "key": "ENABLE_EMAIL_PASSWORD", "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), @@ -177,32 +183,24 @@ class Command(BaseCommand): ) else: self.stdout.write( - self.style.WARNING( - f"{obj.key} configuration already exists" - ) + self.style.WARNING(f"{obj.key} configuration already exists") ) 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": - GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = ( - get_configuration_value( - [ - { - "key": "GOOGLE_CLIENT_ID", - "default": os.environ.get( - "GOOGLE_CLIENT_ID", "" - ), - }, - { - "key": "GOOGLE_CLIENT_SECRET", - "default": os.environ.get( - "GOOGLE_CLIENT_SECRET", "0" - ), - }, - ] - ) + GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID", ""), + }, + { + "key": "GOOGLE_CLIENT_SECRET", + "default": os.environ.get("GOOGLE_CLIENT_SECRET", "0"), + }, + ] ) if bool(GOOGLE_CLIENT_ID) and bool(GOOGLE_CLIENT_SECRET): value = "1" @@ -220,23 +218,17 @@ class Command(BaseCommand): ) ) if key == "IS_GITHUB_ENABLED": - GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = ( - get_configuration_value( - [ - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get( - "GITHUB_CLIENT_ID", "" - ), - }, - { - "key": "GITHUB_CLIENT_SECRET", - "default": os.environ.get( - "GITHUB_CLIENT_SECRET", "0" - ), - }, - ] - ) + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID", ""), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET", "0"), + }, + ] ) if bool(GITHUB_CLIENT_ID) and bool(GITHUB_CLIENT_SECRET): value = "1" @@ -265,9 +257,7 @@ class Command(BaseCommand): }, { "key": "GITLAB_CLIENT_ID", - "default": os.environ.get( - "GITLAB_CLIENT_ID", "" - ), + "default": os.environ.get("GITLAB_CLIENT_ID", ""), }, { "key": "GITLAB_CLIENT_SECRET", diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 09d13441c..692ca350f 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -1,6 +1,7 @@ # Python imports import json import secrets +import os # Django imports from django.core.management.base import BaseCommand, CommandError @@ -8,10 +9,7 @@ from django.utils import timezone from django.conf import settings # Module imports -from plane.license.models import Instance -from plane.db.models import ( - User, -) +from plane.license.models import Instance, InstanceEdition from plane.license.bgtasks.tracer import instance_traces @@ -20,9 +18,7 @@ class Command(BaseCommand): def add_arguments(self, parser): # Positional argument - parser.add_argument( - "machine_signature", type=str, help="Machine signature" - ) + parser.add_argument("machine_signature", type=str, help="Machine signature") def read_package_json(self): with open("package.json", "r") as file: @@ -32,7 +28,6 @@ class Command(BaseCommand): payload = { "instance_key": settings.INSTANCE_KEY, "version": data.get("version", 0.1), - "user_count": User.objects.filter(is_bot=False).count(), } return payload @@ -42,9 +37,7 @@ class Command(BaseCommand): # If instance is None then register this instance if instance is None: - machine_signature = options.get( - "machine_signature", "machine-signature" - ) + machine_signature = options.get("machine_signature", "machine-signature") if not machine_signature: raise CommandError("Machine signature is required") @@ -54,24 +47,23 @@ class Command(BaseCommand): instance = Instance.objects.create( instance_name="Plane Community Edition", instance_id=secrets.token_hex(12), - license_key=None, current_version=payload.get("version"), latest_version=payload.get("version"), last_checked_at=timezone.now(), - user_count=payload.get("user_count", 0), + is_test=os.environ.get("IS_TEST", "0") == "1", + edition=InstanceEdition.PLANE_COMMUNITY.value, ) self.stdout.write(self.style.SUCCESS("Instance registered")) else: - self.stdout.write( - self.style.SUCCESS("Instance already registered") - ) + self.stdout.write(self.style.SUCCESS("Instance already registered")) payload = self.read_package_json() # Update the instance details instance.last_checked_at = timezone.now() - instance.user_count = payload.get("user_count", 0) instance.current_version = payload.get("version") instance.latest_version = payload.get("version") + instance.is_test = os.environ.get("IS_TEST", "0") == "1" + instance.edition = InstanceEdition.PLANE_COMMUNITY.value instance.save() # Call the instance traces task diff --git a/apiserver/plane/license/migrations/0005_rename_product_instance_edition_and_more.py b/apiserver/plane/license/migrations/0005_rename_product_instance_edition_and_more.py new file mode 100644 index 000000000..6746d4e69 --- /dev/null +++ b/apiserver/plane/license/migrations/0005_rename_product_instance_edition_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.15 on 2024-11-19 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("license", "0004_changelog_deleted_at_instance_deleted_at_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="instance", + old_name="product", + new_name="edition", + ), + migrations.RemoveField( + model_name="instance", + name="license_key", + ), + migrations.RemoveField( + model_name="instance", + name="user_count", + ), + migrations.AddField( + model_name="instance", + name="is_test", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="instance", + name="edition", + field=models.CharField(default="PLANE_COMMUNITY", max_length=255), + ), + ] diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py index 0f35f718d..d49524024 100644 --- a/apiserver/plane/license/models/__init__.py +++ b/apiserver/plane/license/models/__init__.py @@ -1 +1 @@ -from .instance import Instance, InstanceAdmin, InstanceConfiguration +from .instance import Instance, InstanceAdmin, InstanceConfiguration, InstanceEdition diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index 0c0581c8b..113b59ce4 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -11,8 +11,8 @@ from plane.db.models import BaseModel ROLE_CHOICES = ((20, "Admin"),) -class ProductTypes(Enum): - PLANE_CE = "plane-ce" +class InstanceEdition(Enum): + PLANE_COMMUNITY = "PLANE_COMMUNITY" class Instance(BaseModel): @@ -20,11 +20,10 @@ class Instance(BaseModel): instance_name = models.CharField(max_length=255) 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=255) latest_version = models.CharField(max_length=255, null=True, blank=True) - product = models.CharField( - max_length=255, default=ProductTypes.PLANE_CE.value + edition = models.CharField( + max_length=255, default=InstanceEdition.PLANE_COMMUNITY.value ) domain = models.TextField(blank=True) # Instance specifics @@ -37,9 +36,8 @@ class Instance(BaseModel): is_setup_done = models.BooleanField(default=False) # signup screen is_signup_screen_visited = models.BooleanField(default=False) - # users - user_count = models.PositiveBigIntegerField(default=0) is_verified = models.BooleanField(default=False) + is_test = models.BooleanField(default=False) class Meta: verbose_name = "Instance" diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index b4f19e52c..842af0959 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -11,24 +11,16 @@ from plane.license.api.views import ( InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, InstanceAdminUserSessionEndpoint, + ChangeLogEndpoint, + InstanceWorkSpaceAvailabilityCheckEndpoint, + InstanceWorkSpaceEndpoint, ) urlpatterns = [ - path( - "", - InstanceEndpoint.as_view(), - name="instance", - ), - path( - "admins/", - InstanceAdminEndpoint.as_view(), - name="instance-admins", - ), - path( - "admins/me/", - InstanceAdminUserMeEndpoint.as_view(), - name="instance-admins", - ), + path("", InstanceEndpoint.as_view(), name="instance"), + path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"), + path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"), + path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"), path( "admins/session/", InstanceAdminUserSessionEndpoint.as_view(), @@ -39,11 +31,7 @@ urlpatterns = [ InstanceAdminSignOutEndpoint.as_view(), name="instance-admins", ), - path( - "admins//", - InstanceAdminEndpoint.as_view(), - name="instance-admins", - ), + path("admins//", InstanceAdminEndpoint.as_view(), name="instance-admins"), path( "configurations/", InstanceConfigurationEndpoint.as_view(), @@ -69,4 +57,14 @@ urlpatterns = [ EmailCredentialCheckEndpoint.as_view(), name="email-credential-check", ), + path( + "workspace-slug-check/", + InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(), + name="instance-workspace-availability", + ), + path( + "workspaces/", + InstanceWorkSpaceEndpoint.as_view(), + name="instance-workspace", + ), ] diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index 4c191feda..72241fe54 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -22,9 +22,7 @@ def get_configuration_value(keys): for item in instance_configuration: if key.get("key") == item.get("key"): if item.get("is_encrypted", False): - environment_list.append( - decrypt_data(item.get("value")) - ) + environment_list.append(decrypt_data(item.get("value"))) else: environment_list.append(item.get("value")) @@ -34,9 +32,7 @@ def get_configuration_value(keys): else: # Get the configuration from os for key in keys: - environment_list.append( - os.environ.get(key.get("key"), key.get("default")) - ) + environment_list.append(os.environ.get(key.get("key"), key.get("default"))) return tuple(environment_list) @@ -44,30 +40,15 @@ def get_configuration_value(keys): def get_email_configuration(): return get_configuration_value( [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, + {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}, + {"key": "EMAIL_HOST_USER", "default": os.environ.get("EMAIL_HOST_USER")}, { "key": "EMAIL_HOST_PASSWORD", "default": os.environ.get("EMAIL_HOST_PASSWORD"), }, - { - "key": "EMAIL_PORT", - "default": os.environ.get("EMAIL_PORT", 587), - }, - { - "key": "EMAIL_USE_TLS", - "default": os.environ.get("EMAIL_USE_TLS", "1"), - }, - { - "key": "EMAIL_USE_SSL", - "default": os.environ.get("EMAIL_USE_SSL", "0"), - }, + {"key": "EMAIL_PORT", "default": os.environ.get("EMAIL_PORT", 587)}, + {"key": "EMAIL_USE_TLS", "default": os.environ.get("EMAIL_USE_TLS", "1")}, + {"key": "EMAIL_USE_SSL", "default": os.environ.get("EMAIL_USE_SSL", "0")}, { "key": "EMAIL_FROM", "default": os.environ.get( diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py index 96c62c2fd..c7a0841ad 100644 --- a/apiserver/plane/middleware/api_log_middleware.py +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -23,13 +23,9 @@ class APITokenLogMiddleware: method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=( - request_body.decode("utf-8") if request_body else None - ), + body=(request_body.decode("utf-8") if request_body else None), response_body=( - response.content.decode("utf-8") - if response.content - else None + response.content.decode("utf-8") if response.content else None ), response_code=response.status_code, ip_address=request.META.get("REMOTE_ADDR", None), diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 5e1ea39a4..ed42dfe19 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -16,16 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration from corsheaders.defaults import default_headers -# OpenTelemetry -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter, -) -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.sdk.resources import Resource -from opentelemetry.instrumentation.django import DjangoInstrumentor - BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -35,19 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", "0")) -# Initialize Django instrumentation -DjangoInstrumentor().instrument() -# Configure the tracer provider -service_name = os.environ.get("SERVICE_NAME", "plane-ce-api") -resource = Resource.create({"service.name": service_name}) -trace.set_tracer_provider(TracerProvider(resource=resource)) -# Configure the OTLP exporter -otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so") -otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint) -span_processor = BatchSpanProcessor(otlp_exporter) -trace.get_tracer_provider().add_span_processor(span_processor) - - # Allowed Hosts ALLOWED_HOSTS = ["*"] @@ -72,7 +49,6 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "django_celery_beat", - "storages", ] # Middlewares @@ -94,20 +70,14 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", ), - "DEFAULT_PERMISSION_CLASSES": ( - "rest_framework.permissions.IsAuthenticated", - ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), - "DEFAULT_FILTER_BACKENDS": ( - "django_filters.rest_framework.DjangoFilterBackend", - ), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler", } # Django Auth Backend -AUTHENTICATION_BACKENDS = ( - "django.contrib.auth.backends.ModelBackend", -) # default +AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default # Root Urls ROOT_URLCONF = "plane.urls" @@ -116,9 +86,7 @@ ROOT_URLCONF = "plane.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - "templates", - ], + "DIRS": ["templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -126,9 +94,9 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - ], + ] }, - }, + } ] @@ -165,9 +133,7 @@ AUTH_USER_MODEL = "db.User" # Database if bool(os.environ.get("DATABASE_URL")): # Parse database configuration from $DATABASE_URL - DATABASES = { - "default": dj_database_url.config(), - } + DATABASES = {"default": dj_database_url.config()} else: DATABASES = { "default": { @@ -200,26 +166,18 @@ else: "default": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, } } # Password validations AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Password reset time the number of seconds the uniquely generated uid will be valid @@ -255,12 +213,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 STORAGES = { "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} -STORAGES["default"] = { - "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" + } } +STORAGES["default"] = {"BACKEND": "plane.settings.storage.S3Storage"} AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") @@ -268,9 +224,9 @@ AWS_REGION = os.environ.get("AWS_REGION", "") AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False AWS_S3_FILE_OVERWRITE = False -AWS_S3_ENDPOINT_URL = os.environ.get( - "AWS_S3_ENDPOINT_URL", None -) or os.environ.get("MINIO_ENDPOINT_URL", None) +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get( + "MINIO_ENDPOINT_URL", None +) if AWS_S3_ENDPOINT_URL and USE_MINIO: parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" @@ -323,9 +279,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( traces_sample_rate=1, send_default_pii=True, environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=float( - os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0) - ), + profiles_sample_rate=float(os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)), ) @@ -347,8 +301,7 @@ POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) # instance key INSTANCE_KEY = os.environ.get( - "INSTANCE_KEY", - "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3", + "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" ) # Skip environment variable configuration @@ -363,9 +316,7 @@ SESSION_ENGINE = "plane.db.models.session" SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800) SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id") SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) -SESSION_SAVE_EVERY_REQUEST = ( - os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" -) +SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" # Admin Cookie ADMIN_SESSION_COOKIE_NAME = "admin-session-id" @@ -384,3 +335,65 @@ SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) APP_BASE_URL = os.environ.get("APP_BASE_URL") HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) + +# Instance Changelog URL +INSTANCE_CHANGELOG_URL = os.environ.get("INSTANCE_CHANGELOG_URL", "") + +ATTACHMENT_MIME_TYPES = [ + # Images + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + "image/tiff", + "image/bmp", + # Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "application/rtf", + # Audio + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/midi", + "audio/x-midi", + "audio/aac", + "audio/flac", + "audio/x-m4a", + # Video + "video/mp4", + "video/mpeg", + "video/ogg", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "video/x-ms-wmv", + # Archives + "application/zip", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + # 3D Models + "model/gltf-binary", + "model/gltf+json", + "application/octet-stream", # for .obj files, but be cautious + # Fonts + "font/ttf", + "font/otf", + "font/woff", + "font/woff2", + # Other + "text/css", + "text/javascript", + "application/json", + "text/xml", + "text/csv", + "application/xml", +] diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index b175e4c83..d33115e2b 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -21,9 +21,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, # noqa - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, } } @@ -44,14 +42,14 @@ LOGGING = { "verbose": { "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", "style": "{", - }, + } }, "handlers": { "console": { "level": "DEBUG", "class": "logging.StreamHandler", "formatter": "verbose", - }, + } }, "loggers": { "django.request": { @@ -59,10 +57,6 @@ LOGGING = { "level": "DEBUG", "propagate": False, }, - "plane": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, + "plane": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, }, } diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 806f83aca..9390a2847 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -59,11 +59,7 @@ LOGGING = { }, }, "loggers": { - "django": { - "handlers": ["console", "file"], - "level": "INFO", - "propagate": True, - }, + "django": {"handlers": ["console", "file"], "level": "INFO", "propagate": True}, "django.request": { "handlers": ["console", "file"], "level": "INFO", diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py new file mode 100644 index 000000000..d82d1c912 --- /dev/null +++ b/apiserver/plane/settings/storage.py @@ -0,0 +1,153 @@ +# Python imports +import os + +# Third party imports +import boto3 +from botocore.exceptions import ClientError +from urllib.parse import quote + +# Module imports +from plane.utils.exception_logger import log_exception +from storages.backends.s3boto3 import S3Boto3Storage + + +class S3Storage(S3Boto3Storage): + def url(self, name, parameters=None, expire=None, http_method=None): + return name + + """S3 storage class to generate presigned URLs for S3 objects""" + + def __init__(self, request=None): + # Get the AWS credentials and bucket name from the environment + self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") + # Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key + self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + # Use the AWS_S3_BUCKET_NAME environment variable for the bucket name + self.aws_storage_bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + # Use the AWS_REGION environment variable for the region + self.aws_region = os.environ.get("AWS_REGION") + # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL + self.aws_s3_endpoint_url = os.environ.get( + "AWS_S3_ENDPOINT_URL" + ) or os.environ.get("MINIO_ENDPOINT_URL") + + if os.environ.get("USE_MINIO") == "1": + # Create an S3 client for MinIO + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=( + f"{request.scheme}://{request.get_host()}" + if request + else self.aws_s3_endpoint_url + ), + config=boto3.session.Config(signature_version="s3v4"), + ) + else: + # Create an S3 client + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=self.aws_s3_endpoint_url, + config=boto3.session.Config(signature_version="s3v4"), + ) + + def generate_presigned_post( + self, object_name, file_type, file_size, expiration=3600 + ): + """Generate a presigned URL to upload an S3 object""" + fields = {"Content-Type": file_type} + + conditions = [ + {"bucket": self.aws_storage_bucket_name}, + ["content-length-range", 1, file_size], + {"Content-Type": file_type}, + ] + + # Add condition for the object name (key) + if object_name.startswith("${filename}"): + conditions.append( + ["starts-with", "$key", object_name[: -len("${filename}")]] + ) + else: + fields["key"] = object_name + conditions.append({"key": object_name}) + + # Generate the presigned POST URL + try: + # Generate a presigned URL for the S3 object + response = self.s3_client.generate_presigned_post( + Bucket=self.aws_storage_bucket_name, + Key=object_name, + Fields=fields, + Conditions=conditions, + ExpiresIn=expiration, + ) + # Handle errors + except ClientError as e: + print(f"Error generating presigned POST URL: {e}") + return None + + return response + + def _get_content_disposition(self, disposition, filename=None): + """Helper method to generate Content-Disposition header value""" + if filename: + # Encode the filename to handle special characters + encoded_filename = quote(filename) + return f"{disposition}; filename*=UTF-8''{encoded_filename}" + return disposition + + def generate_presigned_url( + self, + object_name, + expiration=3600, + http_method="GET", + disposition="inline", + filename=None, + ): + content_disposition = self._get_content_disposition(disposition, filename) + """Generate a presigned URL to share an S3 object""" + try: + response = self.s3_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": self.aws_storage_bucket_name, + "Key": str(object_name), + "ResponseContentDisposition": content_disposition, + }, + ExpiresIn=expiration, + HttpMethod=http_method, + ) + except ClientError as e: + log_exception(e) + return None + + # The response contains the presigned URL + return response + + def get_object_metadata(self, object_name): + """Get the metadata for an S3 object""" + try: + response = self.s3_client.head_object( + Bucket=self.aws_storage_bucket_name, Key=object_name + ) + except ClientError as e: + log_exception(e) + return None + + return { + "ContentType": response.get("ContentType"), + "ContentLength": response.get("ContentLength"), + "LastModified": ( + response.get("LastModified").isoformat() + if response.get("LastModified") + else None + ), + "ETag": response.get("ETag"), + "Metadata": response.get("Metadata", {}), + } diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py index a86b044a3..6a75f7904 100644 --- a/apiserver/plane/settings/test.py +++ b/apiserver/plane/settings/test.py @@ -8,5 +8,5 @@ DEBUG = True EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" INSTALLED_APPS.append( # noqa - "plane.tests", + "plane.tests" ) diff --git a/apiserver/plane/space/serializer/__init__.py b/apiserver/plane/space/serializer/__init__.py index 63dfe9262..ad4e9897d 100644 --- a/apiserver/plane/space/serializer/__init__.py +++ b/apiserver/plane/space/serializer/__init__.py @@ -1,9 +1,5 @@ from .user import UserLiteSerializer -from .issue import ( - LabelLiteSerializer, - StateLiteSerializer, - IssuePublicSerializer, -) +from .issue import LabelLiteSerializer, StateLiteSerializer, IssuePublicSerializer from .state import StateSerializer, StateLiteSerializer diff --git a/apiserver/plane/space/serializer/cycle.py b/apiserver/plane/space/serializer/cycle.py index d4f5d86e0..afa760a59 100644 --- a/apiserver/plane/space/serializer/cycle.py +++ b/apiserver/plane/space/serializer/cycle.py @@ -1,8 +1,6 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - Cycle, -) +from plane.db.models import Cycle class CycleBaseSerializer(BaseSerializer): diff --git a/apiserver/plane/space/serializer/inbox.py b/apiserver/plane/space/serializer/intake.py similarity index 60% rename from apiserver/plane/space/serializer/inbox.py rename to apiserver/plane/space/serializer/intake.py index 48ec7c89d..444c20d42 100644 --- a/apiserver/plane/space/serializer/inbox.py +++ b/apiserver/plane/space/serializer/intake.py @@ -7,44 +7,34 @@ from .user import UserLiteSerializer from .state import StateLiteSerializer from .project import ProjectLiteSerializer from .issue import IssueFlatSerializer, LabelLiteSerializer -from plane.db.models import ( - Issue, - InboxIssue, -) +from plane.db.models import Issue, IntakeIssue -class InboxIssueSerializer(BaseSerializer): +class IntakeIssueSerializer(BaseSerializer): issue_detail = IssueFlatSerializer(source="issue", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: - model = InboxIssue + model = IntakeIssue fields = "__all__" - read_only_fields = [ - "project", - "workspace", - ] + read_only_fields = ["project", "workspace"] -class InboxIssueLiteSerializer(BaseSerializer): +class IntakeIssueLiteSerializer(BaseSerializer): class Meta: - model = InboxIssue + model = IntakeIssue fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] read_only_fields = fields -class IssueStateInboxSerializer(BaseSerializer): +class IssueStateIntakeSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) - issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + issue_intake = IntakeIssueLiteSerializer(read_only=True, many=True) class Meta: model = Issue diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 401e7d719..e1445b4e6 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -22,7 +22,7 @@ from plane.db.models import ( CycleIssue, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -36,28 +36,17 @@ class IssueStateFlatSerializer(BaseSerializer): class Meta: model = Issue - fields = [ - "id", - "sequence_id", - "name", - "state_detail", - "project_detail", - ] + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: model = Label fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class IssueProjectLiteSerializer(BaseSerializer): @@ -65,33 +54,17 @@ class IssueProjectLiteSerializer(BaseSerializer): class Meta: model = Issue - fields = [ - "id", - "project_detail", - "name", - "sequence_id", - ] + fields = ["id", "project_detail", "name", "sequence_id"] read_only_fields = fields class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer( - read_only=True, source="related_issue" - ) + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") class Meta: model = IssueRelation - fields = [ - "issue_detail", - "relation_type", - "related_issue", - "issue", - "id", - ] - read_only_fields = [ - "workspace", - "project", - ] + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] class RelatedIssueSerializer(BaseSerializer): @@ -99,17 +72,8 @@ class RelatedIssueSerializer(BaseSerializer): class Meta: model = IssueRelation - fields = [ - "issue_detail", - "relation_type", - "related_issue", - "issue", - "id", - ] - read_only_fields = [ - "workspace", - "project", - ] + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] class IssueCycleDetailSerializer(BaseSerializer): @@ -163,8 +127,7 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), - issue_id=validated_data.get("issue_id"), + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -174,7 +137,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", @@ -188,22 +151,10 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer): - class Meta: model = IssueReaction - fields = [ - "issue", - "reaction", - "workspace", - "project", - "actor", - ] - read_only_fields = [ - "workspace", - "project", - "issue", - "actor", - ] + fields = ["issue", "reaction", "workspace", "project", "actor"] + read_only_fields = ["workspace", "project", "issue", "actor"] class IssueSerializer(BaseSerializer): @@ -211,9 +162,7 @@ class IssueSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) related_issues = IssueRelationSerializer( read_only=True, source="issue_relation", many=True ) @@ -264,24 +213,15 @@ class CommentReactionLiteSerializer(BaseSerializer): class Meta: model = CommentReaction - fields = [ - "id", - "reaction", - "comment", - "actor_detail", - ] + fields = ["id", "reaction", "comment", "actor_detail"] class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - comment_reactions = CommentReactionLiteSerializer( - read_only=True, many=True - ) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -304,9 +244,7 @@ class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") created_by_detail = UserLiteSerializer(read_only=True, source="created_by") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -334,9 +272,7 @@ class IssueCreateSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data["assignees"] = [ - str(assignee.id) for assignee in instance.assignees.all() - ] + data["assignees"] = [str(assignee.id) for assignee in instance.assignees.all()] data["labels"] = [str(label.id) for label in instance.labels.all()] return data @@ -346,9 +282,7 @@ class IssueCreateSerializer(BaseSerializer): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError( - "Start date cannot exceed target date" - ) + raise serializers.ValidationError("Start date cannot exceed target date") return data def create(self, validated_data): @@ -467,16 +401,9 @@ class CommentReactionSerializer(BaseSerializer): class IssueVoteSerializer(BaseSerializer): - class Meta: model = IssueVote - fields = [ - "issue", - "vote", - "workspace", - "project", - "actor", - ] + fields = ["issue", "vote", "workspace", "project", "actor"] read_only_fields = fields @@ -485,18 +412,9 @@ class IssuePublicSerializer(BaseSerializer): read_only=True, many=True, source="issue_reactions" ) votes = IssueVoteSerializer(read_only=True, many=True) - module_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) - label_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) - assignee_ids = serializers.ListField( - child=serializers.UUIDField(), - required=False, - ) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) class Meta: model = Issue @@ -522,8 +440,4 @@ class IssuePublicSerializer(BaseSerializer): class LabelLiteSerializer(BaseSerializer): class Meta: model = Label - fields = [ - "id", - "name", - "color", - ] + fields = ["id", "name", "color"] diff --git a/apiserver/plane/space/serializer/module.py b/apiserver/plane/space/serializer/module.py index dda1861d1..53840f078 100644 --- a/apiserver/plane/space/serializer/module.py +++ b/apiserver/plane/space/serializer/module.py @@ -1,8 +1,6 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - Module, -) +from plane.db.models import Module class ModuleBaseSerializer(BaseSerializer): diff --git a/apiserver/plane/space/serializer/project.py b/apiserver/plane/space/serializer/project.py index be23e0ce2..f79eef686 100644 --- a/apiserver/plane/space/serializer/project.py +++ b/apiserver/plane/space/serializer/project.py @@ -1,8 +1,6 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - Project, -) +from plane.db.models import Project class ProjectLiteSerializer(BaseSerializer): diff --git a/apiserver/plane/space/serializer/state.py b/apiserver/plane/space/serializer/state.py index 55064ed0e..184f48b40 100644 --- a/apiserver/plane/space/serializer/state.py +++ b/apiserver/plane/space/serializer/state.py @@ -1,27 +1,17 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - State, -) +from plane.db.models import State class StateSerializer(BaseSerializer): class Meta: model = State fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] + read_only_fields = ["workspace", "project"] class StateLiteSerializer(BaseSerializer): class Meta: model = State - fields = [ - "id", - "name", - "color", - "group", - ] + fields = ["id", "name", "color", "group"] read_only_fields = fields diff --git a/apiserver/plane/space/serializer/user.py b/apiserver/plane/space/serializer/user.py index e206073f7..9b707a343 100644 --- a/apiserver/plane/space/serializer/user.py +++ b/apiserver/plane/space/serializer/user.py @@ -1,8 +1,6 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - User, -) +from plane.db.models import User class UserLiteSerializer(BaseSerializer): @@ -13,10 +11,8 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] - read_only_fields = [ - "id", - "is_bot", - ] + read_only_fields = ["id", "is_bot"] diff --git a/apiserver/plane/space/serializer/workspace.py b/apiserver/plane/space/serializer/workspace.py index a31bb3744..4945af96a 100644 --- a/apiserver/plane/space/serializer/workspace.py +++ b/apiserver/plane/space/serializer/workspace.py @@ -1,16 +1,10 @@ # Module imports from .base import BaseSerializer -from plane.db.models import ( - Workspace, -) +from plane.db.models import Workspace class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace - fields = [ - "name", - "slug", - "id", - ] + fields = ["name", "slug", "id"] read_only_fields = fields diff --git a/apiserver/plane/space/urls/__init__.py b/apiserver/plane/space/urls/__init__.py index 054026b00..d9a1f6ec3 100644 --- a/apiserver/plane/space/urls/__init__.py +++ b/apiserver/plane/space/urls/__init__.py @@ -1,10 +1,7 @@ -from .inbox import urlpatterns as inbox_urls +from .intake import urlpatterns as intake_urls from .issue import urlpatterns as issue_urls from .project import urlpatterns as project_urls +from .asset import urlpatterns as asset_urls -urlpatterns = [ - *inbox_urls, - *issue_urls, - *project_urls, -] +urlpatterns = [*intake_urls, *issue_urls, *project_urls, *asset_urls] diff --git a/apiserver/plane/space/urls/asset.py b/apiserver/plane/space/urls/asset.py new file mode 100644 index 000000000..2a5c30a22 --- /dev/null +++ b/apiserver/plane/space/urls/asset.py @@ -0,0 +1,32 @@ +# Django imports +from django.urls import path + +# Module imports +from plane.space.views import ( + EntityAssetEndpoint, + AssetRestoreEndpoint, + EntityBulkAssetEndpoint, +) + +urlpatterns = [ + path( + "assets/v2/anchor//", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor///", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/anchor///bulk/", + EntityBulkAssetEndpoint.as_view(), + name="entity-bulk-asset", + ), +] diff --git a/apiserver/plane/space/urls/inbox.py b/apiserver/plane/space/urls/inbox.py deleted file mode 100644 index 4113235c2..000000000 --- a/apiserver/plane/space/urls/inbox.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.urls import path - - -from plane.space.views import ( - InboxIssuePublicViewSet, - WorkspaceProjectDeployBoardEndpoint, -) - - -urlpatterns = [ - path( - "anchor//inboxes//inbox-issues/", - InboxIssuePublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="inbox-issue", - ), - path( - "anchor//inboxes//inbox-issues//", - InboxIssuePublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="inbox-issue", - ), - path( - "workspaces//project-boards/", - WorkspaceProjectDeployBoardEndpoint.as_view(), - name="workspace-project-boards", - ), -] diff --git a/apiserver/plane/space/urls/intake.py b/apiserver/plane/space/urls/intake.py new file mode 100644 index 000000000..09aca16df --- /dev/null +++ b/apiserver/plane/space/urls/intake.py @@ -0,0 +1,33 @@ +from django.urls import path + + +from plane.space.views import ( + IntakeIssuePublicViewSet, + WorkspaceProjectDeployBoardEndpoint, +) + + +urlpatterns = [ + path( + "anchor//intakes//intake-issues/", + IntakeIssuePublicViewSet.as_view({"get": "list", "post": "create"}), + name="intake-issue", + ), + path( + "anchor//intakes//inbox-issues/", + IntakeIssuePublicViewSet.as_view({"get": "list", "post": "create"}), + name="inbox-issue", + ), + path( + "anchor//intakes//intake-issues//", + IntakeIssuePublicViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="intake-issue", + ), + path( + "workspaces//project-boards/", + WorkspaceProjectDeployBoardEndpoint.as_view(), + name="workspace-project-boards", + ), +] diff --git a/apiserver/plane/space/urls/issue.py b/apiserver/plane/space/urls/issue.py index 8b7dce0e6..2391fd38a 100644 --- a/apiserver/plane/space/urls/issue.py +++ b/apiserver/plane/space/urls/issue.py @@ -17,71 +17,40 @@ urlpatterns = [ ), path( "anchor//issues//comments/", - IssueCommentPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueCommentPublicViewSet.as_view({"get": "list", "post": "create"}), name="issue-comments-project-board", ), path( "anchor//issues//comments//", IssueCommentPublicViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), name="issue-comments-project-board", ), path( "anchor//issues//reactions/", - IssueReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + IssueReactionPublicViewSet.as_view({"get": "list", "post": "create"}), name="issue-reactions-project-board", ), path( "anchor//issues//reactions//", - IssueReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), + IssueReactionPublicViewSet.as_view({"delete": "destroy"}), name="issue-reactions-project-board", ), path( "anchor//comments//reactions/", - CommentReactionPublicViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), + CommentReactionPublicViewSet.as_view({"get": "list", "post": "create"}), name="comment-reactions-project-board", ), path( "anchor//comments//reactions//", - CommentReactionPublicViewSet.as_view( - { - "delete": "destroy", - } - ), + CommentReactionPublicViewSet.as_view({"delete": "destroy"}), name="comment-reactions-project-board", ), path( "anchor//issues//votes/", IssueVotePublicViewSet.as_view( - { - "get": "list", - "post": "create", - "delete": "destroy", - } + {"get": "list", "post": "create", "delete": "destroy"} ), name="issue-vote-project-board", ), diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 9a3cde7ad..250b54e89 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -1,8 +1,8 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField -from django.db.models.functions import Coalesce, JSONObject +from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField +from django.db.models.functions import Coalesce, JSONObject, Concat # Module imports from plane.db.models import ( @@ -16,8 +16,8 @@ from plane.db.models import ( WorkspaceMember, ) -def issue_queryset_grouper(queryset, group_by, sub_group_by): +def issue_queryset_grouper(queryset, group_by, sub_group_by): FIELD_MAPPER = { "label_ids": "labels__id", "assignee_ids": "assignees__id", @@ -25,8 +25,14 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): } annotations_map = { - "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), - "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "assignee_ids": ( + "assignees__id", + ~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True), + ), + "label_ids": ( + "labels__id", + ~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True), + ), "module_ids": ( "issue_module__module_id", ~Q(issue_module__module_id__isnull=True), @@ -34,23 +40,17 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): } default_annotations = { key: Coalesce( - ArrayAgg( - field, - distinct=True, - filter=condition, - ), + ArrayAgg(field, distinct=True, filter=condition), Value([], output_field=ArrayField(UUIDField())), ) for key, (field, condition) in annotations_map.items() - if FIELD_MAPPER.get(key) != group_by - or FIELD_MAPPER.get(key) != sub_group_by + if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by } return queryset.annotate(**default_annotations) def issue_on_results(issues, group_by, sub_group_by): - FIELD_MAPPER = { "labels__id": "label_ids", "assignees__id": "assignee_ids", @@ -98,18 +98,26 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("votes__actor__avatar_asset"), + Value("/"), + ), + ), + default=F("votes__actor__avatar"), + output_field=CharField(), + ), display_name=F("votes__actor__display_name"), - ) + ), ), ), default=None, output_field=JSONField(), ), - filter=Case( - When(votes__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(votes__isnull=False), distinct=True, ), reaction_items=ArrayAgg( @@ -123,6 +131,18 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("issue_reactions__actor__first_name"), last_name=F("issue_reactions__actor__last_name"), avatar=F("issue_reactions__actor__avatar"), + avatar_url=Case( + When( + issue_reactions__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("issue_reactions__actor__avatar_asset"), + Value("/"), + ), + ), + default=F("issue_reactions__actor__avatar"), + output_field=CharField(), + ), display_name=F("issue_reactions__actor__display_name"), ), ), @@ -130,11 +150,7 @@ def issue_on_results(issues, group_by, sub_group_by): default=None, output_field=JSONField(), ), - filter=Case( - When(issue_reactions__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(issue_reactions__isnull=False), distinct=True, ), ).values(*required_fields, "vote_items", "reaction_items") @@ -145,8 +161,7 @@ def issue_on_results(issues, group_by, sub_group_by): def issue_group_values(field, slug, project_id=None, filters=dict): if field == "state_id": queryset = State.objects.filter( - is_triage=False, - workspace__slug=slug, + is_triage=False, workspace__slug=slug ).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) @@ -163,9 +178,7 @@ def issue_group_values(field, slug, project_id=None, filters=dict): if field == "assignees__id": if project_id: return ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - is_active=True, + workspace__slug=slug, project_id=project_id, is_active=True ).values_list("member_id", flat=True) else: return list( @@ -174,17 +187,17 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ).values_list("member_id", flat=True) ) if field == "issue_module__module_id": - queryset = Module.objects.filter( - workspace__slug=slug, - ).values_list("id", flat=True) + queryset = Module.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: return list(queryset) + ["None"] if field == "cycle_id": - queryset = Cycle.objects.filter( - workspace__slug=slug, - ).values_list("id", flat=True) + queryset = Cycle.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: @@ -195,21 +208,9 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ) return list(queryset) if field == "priority": - return [ - "low", - "medium", - "high", - "urgent", - "none", - ] + return ["low", "medium", "high", "urgent", "none"] if field == "state__group": - return [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + return ["backlog", "unstarted", "started", "completed", "cancelled"] if field == "target_date": queryset = ( Issue.issue_objects.filter(workspace__slug=slug) diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index f5e860d87..afdc1d337 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -14,7 +14,7 @@ from .issue import ( ProjectIssuesPublicEndpoint, ) -from .inbox import InboxIssuePublicViewSet +from .intake import IntakeIssuePublicViewSet from .cycle import ProjectCyclesEndpoint @@ -23,3 +23,5 @@ from .module import ProjectModulesEndpoint from .state import ProjectStatesEndpoint from .label import ProjectLabelsEndpoint + +from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py new file mode 100644 index 000000000..2c6722038 --- /dev/null +++ b/apiserver/plane/space/views/asset.py @@ -0,0 +1,241 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, FileAsset +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata + + +class EntityAssetEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method == "GET": + permission_classes = [AllowAny] + else: + permission_classes = [IsAuthenticated] + return [permission() for permission in permission_classes] + + def get(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Requested resource could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # get the asset id + asset = FileAsset.objects.get( + workspace_id=deploy_board.workspace_id, + pk=pk, + entity_type__in=[ + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION, + ], + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url(object_name=asset.asset.name) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + def post(self, request, anchor): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + # Get the asset + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{deploy_board.workspace_id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size}, + asset=asset_key, + size=size, + workspace=deploy_board.workspace, + created_by=request.user, + entity_type=entity_type, + project_id=deploy_board.project_id, + comment_id=entity_identifier, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + # get the asset id + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(str(asset.id)) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["attributes", "is_uploaded"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + def post(self, request, anchor, asset_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + # Get the asset + asset = FileAsset.all_objects.get(id=asset_id, workspace=deploy_board.workspace) + asset.is_deleted = False + asset.deleted_at = None + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class EntityBulkAssetEndpoint(BaseAPIView): + """Endpoint to bulk update assets.""" + + def post(self, request, anchor, entity_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST + ) + + # get the asset id + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace=deploy_board.workspace, + project_id=deploy_board.project_id, + ) + + asset = assets.first() + + # Check if the asset is uploaded + if not asset: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + # update the attributes + assets.update(comment_id=entity_id) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py index 6b18a1546..82809f08d 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -41,18 +41,11 @@ class TimezoneMixin: class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None - permission_classes = [ - IsAuthenticated, - ] + permission_classes = [IsAuthenticated] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) + filter_backends = (DjangoFilterBackend, SearchFilter) - authentication_classes = [ - BaseSessionAuthentication, - ] + authentication_classes = [BaseSessionAuthentication] filterset_fields = [] @@ -63,9 +56,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): return self.model.objects.all() except Exception as e: log_exception(e) - raise APIException( - "Please check the view", status.HTTP_400_BAD_REQUEST - ) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) def handle_exception(self, exc): """ @@ -138,22 +129,15 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator): - permission_classes = [ - IsAuthenticated, - ] + permission_classes = [IsAuthenticated] - filter_backends = ( - DjangoFilterBackend, - SearchFilter, - ) + filter_backends = (DjangoFilterBackend, SearchFilter) filterset_fields = [] search_fields = [] - authentication_classes = [ - BaseSessionAuthentication, - ] + authentication_classes = [BaseSessionAuthentication] def filter_queryset(self, queryset): for backend in list(self.filter_backends): diff --git a/apiserver/plane/space/views/cycle.py b/apiserver/plane/space/views/cycle.py index 49cc82e89..399d626a1 100644 --- a/apiserver/plane/space/views/cycle.py +++ b/apiserver/plane/space/views/cycle.py @@ -5,23 +5,17 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.db.models import ( - DeployBoard, - Cycle, -) +from plane.db.models import DeployBoard, Cycle class ProjectCyclesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: return Response( - {"error": "Invalid anchor"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND ) cycles = Cycle.objects.filter( @@ -29,7 +23,4 @@ class ProjectCyclesEndpoint(BaseAPIView): project_id=deploy_board.project_id, ).values("id", "name") - return Response( - cycles, - status=status.HTTP_200_OK, - ) + return Response(cycles, status=status.HTTP_200_OK) diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/intake.py similarity index 67% rename from apiserver/plane/space/views/inbox.py rename to apiserver/plane/space/views/intake.py index 3358ff1d3..0d39dd276 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/intake.py @@ -12,31 +12,22 @@ from rest_framework.response import Response # Module imports from .base import BaseViewSet -from plane.db.models import ( - InboxIssue, - Issue, - State, - IssueLink, - IssueAttachment, - DeployBoard, -) +from plane.db.models import IntakeIssue, Issue, State, IssueLink, FileAsset, DeployBoard from plane.app.serializers import ( IssueSerializer, - InboxIssueSerializer, + IntakeIssueSerializer, IssueCreateSerializer, - IssueStateInboxSerializer, + IssueStateIntakeSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activities_task import issue_activity -class InboxIssuePublicViewSet(BaseViewSet): - serializer_class = InboxIssueSerializer - model = InboxIssue +class IntakeIssuePublicViewSet(BaseViewSet): + serializer_class = IntakeIssueSerializer + model = IntakeIssue - filterset_fields = [ - "status", - ] + filterset_fields = ["status"] def get_queryset(self): project_deploy_board = DeployBoard.objects.get( @@ -48,42 +39,39 @@ class InboxIssuePublicViewSet(BaseViewSet): super() .get_queryset() .filter( - Q(snoozed_till__gte=timezone.now()) - | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - inbox_id=self.kwargs.get("inbox_id"), + intake_id=self.kwargs.get("intake_id"), ) .select_related("issue", "workspace", "project") ) - return InboxIssue.objects.none() + return IntakeIssue.objects.none() - def list(self, request, anchor, inbox_id): + def list(self, request, anchor, intake_id): project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - if project_deploy_board.inbox is None: + if project_deploy_board.intake is None: return Response( - {"error": "Inbox is not enabled for this Project Board"}, + {"error": "Intake is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST, ) filters = issue_filters(request.query_params, "GET") issues = ( Issue.objects.filter( - issue_inbox__inbox_id=inbox_id, + issue_intake__intake_id=intake_id, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, ) .filter(**filters) - .annotate(bridge_id=F("issue_inbox__id")) + .annotate(bridge_id=F("issue_intake__id")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels") - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + .order_by("issue_intake__snoozed_till", "issue_intake__status") .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -95,8 +83,9 @@ class InboxIssuePublicViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -104,33 +93,29 @@ class InboxIssuePublicViewSet(BaseViewSet): ) .prefetch_related( Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( + "issue_intake", + queryset=IntakeIssue.objects.only( "status", "duplicate_to", "snoozed_till", "source" ), ) ) ) - issues_data = IssueStateInboxSerializer(issues, many=True).data - return Response( - issues_data, - status=status.HTTP_200_OK, - ) + issues_data = IssueStateIntakeSerializer(issues, many=True).data + return Response(issues_data, status=status.HTTP_200_OK) - def create(self, request, anchor, inbox_id): + def create(self, request, anchor, intake_id): project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - if project_deploy_board.inbox is None: + if project_deploy_board.intake is None: return Response( - {"error": "Inbox is not enabled for this Project Board"}, + {"error": "Intake is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST, ) if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) # Check for valid priority @@ -142,15 +127,14 @@ class InboxIssuePublicViewSet(BaseViewSet): "none", ]: return Response( - {"error": "Invalid priority"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) # Create or get state state, _ = State.objects.get_or_create( name="Triage", group="backlog", - description="Default state for managing all Inbox Issues", + description="Default state for managing all Intake Issues", project_id=project_deploy_board.project_id, color="#ff7700", ) @@ -177,37 +161,37 @@ class InboxIssuePublicViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), ) - # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, + # create an intake issue + IntakeIssue.objects.create( + intake_id=intake_id, project_id=project_deploy_board.project_id, issue=issue, - source=request.data.get("source", "in-app"), + source=request.data.get("source", "IN-APP"), ) - serializer = IssueStateInboxSerializer(issue) + serializer = IssueStateIntakeSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request, anchor, inbox_id, pk): + def partial_update(self, request, anchor, intake_id, pk): project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - if project_deploy_board.inbox is None: + if project_deploy_board.intake is None: return Response( - {"error": "Inbox is not enabled for this Project Board"}, + {"error": "Intake is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox_issue = InboxIssue.objects.get( + intake_issue = IntakeIssue.objects.get( pk=pk, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, - inbox_id=inbox_id, + intake_id=intake_id, ) # Get the project member - if str(inbox_issue.created_by_id) != str(request.user.id): + if str(intake_issue.created_by_id) != str(request.user.id): return Response( - {"error": "You cannot edit inbox issues"}, + {"error": "You cannot edit intake issues"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -215,7 +199,7 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_data = request.data.pop("issue", False) issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=intake_issue.issue_id, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, ) @@ -228,9 +212,7 @@ class InboxIssuePublicViewSet(BaseViewSet): "description": issue_data.get("description", issue.description), } - issue_serializer = IssueCreateSerializer( - issue, data=issue_data, partial=True - ) + issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True) if issue_serializer.is_valid(): current_instance = issue @@ -244,63 +226,60 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_deploy_board.project_id), current_instance=json.dumps( - IssueSerializer(current_instance).data, - cls=DjangoJSONEncoder, + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), epoch=int(timezone.now().timestamp()), ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) - return Response( - issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def retrieve(self, request, anchor, inbox_id, pk): + def retrieve(self, request, anchor, intake_id, pk): project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - if project_deploy_board.inbox is None: + if project_deploy_board.intake is None: return Response( - {"error": "Inbox is not enabled for this Project Board"}, + {"error": "Intake is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox_issue = InboxIssue.objects.get( + intake_issue = IntakeIssue.objects.get( pk=pk, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, - inbox_id=inbox_id, + intake_id=intake_id, ) issue = Issue.objects.get( - pk=inbox_issue.issue_id, + pk=intake_issue.issue_id, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, ) - serializer = IssueStateInboxSerializer(issue) + serializer = IssueStateIntakeSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def destroy(self, request, anchor, inbox_id, pk): + def destroy(self, request, anchor, intake_id, pk): project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - if project_deploy_board.inbox is None: + if project_deploy_board.intake is None: return Response( - {"error": "Inbox is not enabled for this Project Board"}, + {"error": "Intake is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox_issue = InboxIssue.objects.get( + intake_issue = IntakeIssue.objects.get( pk=pk, workspace_id=project_deploy_board.workspace_id, project_id=project_deploy_board.project_id, - inbox_id=inbox_id, + intake_id=intake_id, ) - if str(inbox_issue.created_by_id) != str(request.user.id): + if str(intake_issue.created_by_id) != str(request.user.id): return Response( - {"error": "You cannot delete inbox issue"}, + {"error": "You cannot delete intake issue"}, status=status.HTTP_400_BAD_REQUEST, ) - inbox_issue.delete() + intake_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index fe7a4e13a..a1ab332f9 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -19,7 +19,10 @@ from django.db.models import ( Value, OuterRef, Func, + CharField, + Subquery, ) +from django.db.models.functions import Concat # Third Party imports from rest_framework.response import Response @@ -39,10 +42,7 @@ from plane.space.utils.grouper import ( from plane.utils.order_queryset import order_issue_queryset -from plane.utils.paginator import ( - GroupedOffsetPaginator, - SubGroupedOffsetPaginator, -) +from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.serializers import ( CommentReactionSerializer, IssueCommentSerializer, @@ -59,16 +59,15 @@ from plane.db.models import ( DeployBoard, IssueVote, ProjectPublicMember, - IssueAttachment, + FileAsset, + CycleIssue, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters class ProjectIssuesPublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): filters = issue_filters(request.query_params, "GET") @@ -79,17 +78,14 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ).first() if not deploy_board: return Response( - {"error": "Project is not published"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND ) project_id = deploy_board.entity_identifier slug = deploy_board.workspace.slug issue_queryset = ( - Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id - ) + Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( @@ -99,12 +95,15 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ) ) .prefetch_related( - Prefetch( - "votes", - queryset=IssueVote.objects.select_related("actor"), + Prefetch("votes", queryset=IssueVote.objects.select_related("actor")) + ) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] ) ) - .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -112,17 +111,16 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -133,8 +131,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): # Issue queryset issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, + issue_queryset=issue_queryset, order_by_param=order_by_param ) # Group by @@ -143,9 +140,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): # issue queryset issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) if group_by: @@ -163,9 +158,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -183,10 +176,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__status=True), archived_at__isnull=True, is_draft=False, ), @@ -198,9 +191,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): 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, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( @@ -211,10 +202,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ), 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), + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__status=True), archived_at__isnull=True, is_draft=False, ), @@ -225,9 +216,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): request=request, queryset=issue_queryset, on_results=lambda issues: issue_on_results( - group_by=group_by, - issues=issues, - sub_group_by=sub_group_by, + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), ) @@ -236,28 +225,20 @@ class IssueCommentPublicViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment - filterset_fields = [ - "issue__id", - "workspace__id", - ] + filterset_fields = ["issue__id", "workspace__id"] def get_permissions(self): if self.action in ["list", "retrieve"]: - self.permission_classes = [ - AllowAny, - ] + self.permission_classes = [AllowAny] else: - self.permission_classes = [ - IsAuthenticated, - ] + self.permission_classes = [IsAuthenticated] return super(IssueCommentPublicViewSet, self).get_permissions() def get_queryset(self): try: project_deploy_board = DeployBoard.objects.get( - anchor=self.kwargs.get("anchor"), - entity_name="project", + anchor=self.kwargs.get("anchor"), entity_name="project" ) if project_deploy_board.is_comments_enabled: return self.filter_queryset( @@ -306,9 +287,7 @@ class IssueCommentPublicViewSet(BaseViewSet): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps( - serializer.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_deploy_board.project_id), @@ -322,8 +301,7 @@ class IssueCommentPublicViewSet(BaseViewSet): ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, - member=request.user, + project_id=project_deploy_board.project_id, member=request.user ) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -340,9 +318,7 @@ class IssueCommentPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) comment = IssueComment.objects.get(pk=pk, actor=request.user) - serializer = IssueCommentSerializer( - comment, data=request.data, partial=True - ) + serializer = IssueCommentSerializer(comment, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -352,8 +328,7 @@ class IssueCommentPublicViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_deploy_board.project_id), current_instance=json.dumps( - IssueCommentSerializer(comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder ), epoch=int(timezone.now().timestamp()), ) @@ -370,10 +345,7 @@ class IssueCommentPublicViewSet(BaseViewSet): {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) - comment = IssueComment.objects.get( - pk=pk, - actor=request.user, - ) + comment = IssueComment.objects.get(pk=pk, actor=request.user) issue_activity.delay( type="comment.activity.deleted", requested_data=json.dumps({"comment_id": str(pk)}), @@ -381,8 +353,7 @@ class IssueCommentPublicViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_deploy_board.project_id), current_instance=json.dumps( - IssueCommentSerializer(comment).data, - cls=DjangoJSONEncoder, + IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder ), epoch=int(timezone.now().timestamp()), ) @@ -439,14 +410,11 @@ class IssueReactionPublicViewSet(BaseViewSet): ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, - member=request.user, + project_id=project_deploy_board.project_id, member=request.user ) issue_activity.delay( type="issue_reaction.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), @@ -479,10 +447,7 @@ class IssueReactionPublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), current_instance=json.dumps( - { - "reaction": str(reaction_code), - "identifier": str(issue_reaction.id), - } + {"reaction": str(reaction_code), "identifier": str(issue_reaction.id)} ), epoch=int(timezone.now().timestamp()), ) @@ -538,14 +503,11 @@ class CommentReactionPublicViewSet(BaseViewSet): ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, - member=request.user, + project_id=project_deploy_board.project_id, member=request.user ) issue_activity.delay( type="comment_reaction.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -598,8 +560,7 @@ class IssueVotePublicViewSet(BaseViewSet): def get_queryset(self): try: project_deploy_board = DeployBoard.objects.get( - workspace__slug=self.kwargs.get("anchor"), - entity_name="project", + workspace__slug=self.kwargs.get("anchor"), entity_name="project" ) if project_deploy_board.is_votes_enabled: return ( @@ -629,16 +590,13 @@ class IssueVotePublicViewSet(BaseViewSet): is_active=True, ).exists(): _ = ProjectPublicMember.objects.get_or_create( - project_id=project_deploy_board.project_id, - member=request.user, + project_id=project_deploy_board.project_id, member=request.user ) issue_vote.vote = request.data.get("vote", 1) issue_vote.save() issue_activity.delay( type="issue_vote.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), @@ -665,10 +623,7 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), current_instance=json.dumps( - { - "vote": str(issue_vote.vote), - "identifier": str(issue_vote.id), - } + {"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)} ), epoch=int(timezone.now().timestamp()), ) @@ -677,9 +632,7 @@ class IssueVotePublicViewSet(BaseViewSet): class IssueRetrievePublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor, issue_id): deploy_board = DeployBoard.objects.get(anchor=anchor) @@ -692,13 +645,22 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) .annotate( label_ids=Coalesce( ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=Q( + ~Q(labels__id__isnull=True) + & Q(label_issue__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -706,8 +668,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), + filter=Q( + ~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True) + & Q(issue_assignee__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -715,7 +680,9 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -723,16 +690,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): .prefetch_related( Prefetch( "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), + queryset=IssueReaction.objects.select_related("issue", "actor"), ) ) .prefetch_related( - Prefetch( - "votes", - queryset=IssueVote.objects.select_related("actor"), - ) + Prefetch("votes", queryset=IssueVote.objects.select_related("actor")) ) .annotate( vote_items=ArrayAgg( @@ -746,9 +708,23 @@ class IssueRetrievePublicEndpoint(BaseAPIView): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), - display_name=F( - "votes__actor__display_name" + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("votes__actor__avatar_asset"), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), ), + display_name=F("votes__actor__display_name"), ), ), ), @@ -770,13 +746,25 @@ class IssueRetrievePublicEndpoint(BaseAPIView): reaction=F("issue_reactions__reaction"), actor_details=JSONObject( id=F("issue_reactions__actor__id"), - first_name=F( - "issue_reactions__actor__first_name" - ), - last_name=F( - "issue_reactions__actor__last_name" - ), + first_name=F("issue_reactions__actor__first_name"), + last_name=F("issue_reactions__actor__last_name"), avatar=F("issue_reactions__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("votes__actor__avatar_asset"), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), + ), display_name=F( "issue_reactions__actor__display_name" ), diff --git a/apiserver/plane/space/views/label.py b/apiserver/plane/space/views/label.py index 2e0f99b7c..ad0c8f0ca 100644 --- a/apiserver/plane/space/views/label.py +++ b/apiserver/plane/space/views/label.py @@ -5,23 +5,17 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView -from plane.db.models import ( - DeployBoard, - Label, -) +from plane.db.models import DeployBoard, Label class ProjectLabelsEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: return Response( - {"error": "Invalid anchor"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND ) labels = Label.objects.filter( @@ -29,7 +23,4 @@ class ProjectLabelsEndpoint(BaseAPIView): project_id=deploy_board.project_id, ).values("id", "name", "color", "parent") - return Response( - labels, - status=status.HTTP_200_OK, - ) + return Response(labels, status=status.HTTP_200_OK) diff --git a/apiserver/plane/space/views/module.py b/apiserver/plane/space/views/module.py index f52f42331..7db676537 100644 --- a/apiserver/plane/space/views/module.py +++ b/apiserver/plane/space/views/module.py @@ -5,23 +5,17 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.db.models import ( - DeployBoard, - Module, -) +from plane.db.models import DeployBoard, Module class ProjectModulesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: return Response( - {"error": "Invalid anchor"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND ) modules = Module.objects.filter( @@ -29,7 +23,4 @@ class ProjectModulesEndpoint(BaseAPIView): project_id=deploy_board.project_id, ).values("id", "name") - return Response( - modules, - status=status.HTTP_200_OK, - ) + return Response(modules, status=status.HTTP_200_OK) diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index f1606a798..1574871ef 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -1,8 +1,5 @@ # Django imports -from django.db.models import ( - Exists, - OuterRef, -) +from django.db.models import Exists, OuterRef # Third Party imports from rest_framework.response import Response @@ -16,9 +13,7 @@ from plane.db.models import Project, DeployBoard, ProjectMember class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): project_deploy_board = DeployBoard.objects.get( @@ -29,9 +24,7 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter( @@ -42,9 +35,7 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): .annotate( is_public=Exists( DeployBoard.objects.filter( - anchor=anchor, - project_id=OuterRef("pk"), - entity_name="project", + anchor=anchor, project_id=OuterRef("pk"), entity_name="project" ) ) ) @@ -63,9 +54,7 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): class WorkspaceProjectAnchorEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, slug, project_id): project_deploy_board = DeployBoard.objects.get( @@ -76,9 +65,7 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView): class ProjectMembersEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() diff --git a/apiserver/plane/space/views/state.py b/apiserver/plane/space/views/state.py index 7ffcef5b9..39f2b1bfd 100644 --- a/apiserver/plane/space/views/state.py +++ b/apiserver/plane/space/views/state.py @@ -8,23 +8,17 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.db.models import ( - DeployBoard, - State, -) +from plane.db.models import DeployBoard, State class ProjectStatesEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] + permission_classes = [AllowAny] def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: return Response( - {"error": "Invalid anchor"}, - status=status.HTTP_404_NOT_FOUND, + {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND ) states = State.objects.filter( @@ -33,7 +27,4 @@ class ProjectStatesEndpoint(BaseAPIView): project_id=deploy_board.project_id, ).values("name", "group", "color", "id", "sequence") - return Response( - states, - status=status.HTTP_200_OK, - ) + return Response(states, status=status.HTTP_200_OK) diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py index f6843c1b6..e3209a281 100644 --- a/apiserver/plane/tests/api/base.py +++ b/apiserver/plane/tests/api/base.py @@ -8,9 +8,7 @@ from plane.app.views.authentication import get_tokens_for_user class BaseAPITest(APITestCase): def setUp(self): - self.client = APIClient( - HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10" - ) + self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") class AuthenticatedAPITest(BaseAPITest): diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py index 36a0f7a24..5d7beabdf 100644 --- a/apiserver/plane/tests/api/test_authentication.py +++ b/apiserver/plane/tests/api/test_authentication.py @@ -28,9 +28,7 @@ class SignInEndpointTests(BaseAPITest): def test_email_validity(self): url = reverse("sign-in") response = self.client.post( - url, - {"email": "useremail.com", "password": "user@123"}, - format="json", + url, {"email": "useremail.com", "password": "user@123"}, format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( @@ -40,9 +38,7 @@ class SignInEndpointTests(BaseAPITest): def test_password_validity(self): url = reverse("sign-in") response = self.client.post( - url, - {"email": "user@plane.so", "password": "user123"}, - format="json", + url, {"email": "user@plane.so", "password": "user123"}, format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -55,9 +51,7 @@ class SignInEndpointTests(BaseAPITest): def test_user_exists(self): url = reverse("sign-in") response = self.client.post( - url, - {"email": "user@email.so", "password": "user123"}, - format="json", + url, {"email": "user@email.so", "password": "user123"}, format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -71,16 +65,11 @@ class SignInEndpointTests(BaseAPITest): url = reverse("sign-in") response = self.client.post( - url, - {"email": "user@plane.so", "password": "user@123"}, - format="json", + url, {"email": "user@plane.so", "password": "user@123"}, format="json" ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data.get("user").get("email"), - "user@plane.so", - ) + self.assertEqual(response.data.get("user").get("email"), "user@plane.so") class MagicLinkGenerateEndpointTests(BaseAPITest): @@ -97,9 +86,7 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): def test_email_validity(self): url = reverse("magic-generate") - response = self.client.post( - url, {"email": "useremail.com"}, format="json" - ) + response = self.client.post(url, {"email": "useremail.com"}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data, {"error": "Please provide a valid email address."} @@ -111,9 +98,7 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): ri = redis_instance() ri.delete("magic_user@plane.so") - response = self.client.post( - url, {"email": "user@plane.so"}, format="json" - ) + response = self.client.post(url, {"email": "user@plane.so"}, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) def test_max_generate_attempt(self): @@ -123,22 +108,13 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): ri.delete("magic_user@plane.so") for _ in range(4): - response = self.client.post( - url, - {"email": "user@plane.so"}, - format="json", - ) + response = self.client.post(url, {"email": "user@plane.so"}, format="json") - response = self.client.post( - url, - {"email": "user@plane.so"}, - format="json", - ) + response = self.client.post(url, {"email": "user@plane.so"}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, - {"error": "Max attempts exhausted. Please try again later."}, + response.data, {"error": "Max attempts exhausted. Please try again later."} ) @@ -153,9 +129,7 @@ class MagicSignInEndpointTests(BaseAPITest): url = reverse("magic-sign-in") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "User token and key are required"} - ) + self.assertEqual(response.data, {"error": "User token and key are required"}) def test_expired_invalid_magic_link(self): ri = redis_instance() @@ -169,8 +143,7 @@ class MagicSignInEndpointTests(BaseAPITest): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, - {"error": "The magic code/link has expired please try again"}, + response.data, {"error": "The magic code/link has expired please try again"} ) def test_invalid_magic_code(self): @@ -188,8 +161,7 @@ class MagicSignInEndpointTests(BaseAPITest): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, - {"error": "Your login code was incorrect. Please try again."}, + response.data, {"error": "Your login code was incorrect. Please try again."} ) def test_magic_code_sign_in(self): @@ -205,12 +177,7 @@ class MagicSignInEndpointTests(BaseAPITest): url = reverse("magic-sign-in") response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": token}, - format="json", + url, {"key": "magic_user@plane.so", "token": token}, format="json" ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data.get("user").get("email"), - "user@plane.so", - ) + self.assertEqual(response.data.get("user").get("email"), "user@plane.so") diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index aac6459b3..e3870a393 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -1,6 +1,4 @@ -"""plane URL Configuration - -""" +"""plane URL Configuration""" from django.conf import settings from django.urls import include, path, re_path @@ -24,7 +22,7 @@ if settings.DEBUG: import debug_toolbar urlpatterns = [ - re_path(r"^__debug__/", include(debug_toolbar.urls)), + re_path(r"^__debug__/", include(debug_toolbar.urls)) ] + urlpatterns except ImportError: pass diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index eda3b30ac..7527a3524 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -4,16 +4,7 @@ from itertools import groupby # Django import from django.db import models -from django.db.models import ( - Case, - CharField, - Count, - F, - Sum, - Value, - When, - FloatField, -) +from django.db.models import Case, CharField, Count, F, Sum, Value, When, FloatField from django.db.models.functions import ( Coalesce, Concat, @@ -41,9 +32,7 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute): def extract_axis(queryset, x_axis): # Format the dimension when the axis is in date if x_axis in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension( - queryset, x_axis, "dimension" - ) + queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension") return queryset, "dimension" else: return queryset.annotate(dimension=F(x_axis)), "dimension" @@ -68,9 +57,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): # if segment in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension( - queryset, segment, "segmented" - ) + queryset = annotate_with_monthly_dimension(queryset, segment, "segmented") segment = "segmented" queryset = queryset.values(x_axis) @@ -85,9 +72,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): ), dimension_ex=Coalesce("dimension", Value("null")), ).values("dimension") - queryset = ( - queryset.annotate(segment=F(segment)) if segment else queryset - ) + queryset = queryset.annotate(segment=F(segment)) if segment else queryset queryset = ( queryset.values("dimension", "segment") if segment @@ -100,9 +85,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): queryset = queryset.annotate( estimate=Sum(Cast("estimate_point__value", FloatField())) ).order_by(x_axis) - queryset = ( - queryset.annotate(segment=F(segment)) if segment else queryset - ) + queryset = queryset.annotate(segment=F(segment)) if segment else queryset queryset = ( queryset.values("dimension", "segment", "estimate") if segment @@ -112,22 +95,13 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): result_values = list(queryset) grouped_data = { str(key): list(items) - for key, items in groupby( - result_values, key=lambda x: x[str("dimension")] - ) + for key, items in groupby(result_values, key=lambda x: x[str("dimension")]) } return sort_data(grouped_data, temp_axis) -def burndown_plot( - queryset, - slug, - project_id, - plot_type, - cycle_id=None, - module_id=None, -): +def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_id=None): # Total Issues in Cycle or Module total_issues = queryset.total_issues # check whether the estimate is a point or not @@ -138,10 +112,11 @@ def burndown_plot( estimate__type="points", ).exists() if estimate_type and plot_type == "points" and cycle_id: - issue_estimates = Issue.objects.filter( + issue_estimates = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, estimate_point__isnull=False, ).values_list("estimate_point__value", flat=True) @@ -149,10 +124,11 @@ def burndown_plot( total_estimate_points = sum(issue_estimates) if estimate_type and plot_type == "points" and module_id: - issue_estimates = Issue.objects.filter( + issue_estimates = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, issue_module__module_id=module_id, + issue_module__deleted_at__isnull=True, estimate_point__isnull=False, ).values_list("estimate_point__value", flat=True) @@ -163,10 +139,8 @@ def burndown_plot( if queryset.end_date and queryset.start_date: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) - for x in range( - (queryset.end_date - queryset.start_date).days + 1 - ) + (queryset.start_date + timedelta(days=x)).date() + for x in range((queryset.end_date - queryset.start_date).days + 1) ] else: date_range = [] @@ -179,6 +153,7 @@ def burndown_plot( workspace__slug=slug, project_id=project_id, issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, estimate_point__isnull=False, ) .annotate(date=TruncDate("completed_at")) @@ -192,6 +167,7 @@ def burndown_plot( workspace__slug=slug, project_id=project_id, issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, ) .annotate(date=TruncDate("completed_at")) .values("date") @@ -203,10 +179,8 @@ def burndown_plot( if module_id: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) - for x in range( - (queryset.target_date - queryset.start_date).days + 1 - ) + (queryset.start_date + timedelta(days=x)) + for x in range((queryset.target_date - queryset.start_date).days + 1) ] chart_data = {str(date): 0 for date in date_range} @@ -217,6 +191,7 @@ def burndown_plot( workspace__slug=slug, project_id=project_id, issue_module__module_id=module_id, + issue_module__deleted_at__isnull=True, estimate_point__isnull=False, ) .annotate(date=TruncDate("completed_at")) @@ -230,6 +205,7 @@ def burndown_plot( workspace__slug=slug, project_id=project_id, issue_module__module_id=module_id, + issue_module__deleted_at__isnull=True, ) .annotate(date=TruncDate("completed_at")) .values("date") diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index bda942899..1b3e2cb1c 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -28,16 +28,16 @@ def cache_response(timeout=60 * 60, path=None, user=True): auth_header = ( None if request.user.is_anonymous - else str(request.user.id) if user else None + else str(request.user.id) + if user + else None ) custom_path = path if path is not None else request.get_full_path() key = generate_cache_key(custom_path, auth_header) cached_result = cache.get(key) if cached_result is not None: - return Response( - cached_result["data"], status=cached_result["status"] - ) + return Response(cached_result["data"], status=cached_result["status"]) response = view_func(instance, request, *args, **kwargs) if response.status_code == 200 and not settings.DEBUG: cache.set( @@ -67,7 +67,9 @@ def invalidate_cache_directly( auth_header = ( None if request and request.user.is_anonymous - else str(request.user.id) if user else None + else str(request.user.id) + if user + else None ) key = generate_cache_key(custom_path, auth_header) diff --git a/apiserver/plane/utils/constants.py b/apiserver/plane/utils/constants.py index a4d1e1ea6..0d5e64a20 100644 --- a/apiserver/plane/utils/constants.py +++ b/apiserver/plane/utils/constants.py @@ -32,4 +32,36 @@ RESTRICTED_WORKSPACE_SLUGS = [ "signin", "signup", "config", + "live", + "admin", + "m", + "import", + "importers", + "integrations", + "integration", + "configuration", + "initiatives", + "initiative", + "config", + "workflow", + "workflows", + "epics", + "epic", + "story", + "mobile", + "dashboard", + "desktop", + "onload", + "real-time", + "one", + "pages", + "mobile", + "business", + "pro", + "settings", + "monitor", + "license", + "licenses", + "instances", + "instance", ] diff --git a/apiserver/plane/utils/global_paginator.py b/apiserver/plane/utils/global_paginator.py index e9ed735d5..338d86117 100644 --- a/apiserver/plane/utils/global_paginator.py +++ b/apiserver/plane/utils/global_paginator.py @@ -20,9 +20,7 @@ class PaginateCursor: try: bits = value.split(":") if len(bits) != 3: - raise ValueError( - "Cursor must be in the format 'value:offset:is_prev'" - ) + raise ValueError("Cursor must be in the format 'value:offset:is_prev'") return self(int(bits[0]), int(bits[1]), int(bits[2])) except (TypeError, ValueError) as e: raise ValueError(f"Invalid cursor format: {e}") diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index ba52bca03..e139cdcc5 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -25,28 +25,30 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): } annotations_map = { - "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), - "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "assignee_ids": ( + "assignees__id", + ~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True), + ), + "label_ids": ( + "labels__id", + ~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True), + ), "module_ids": ( "issue_module__module_id", ( ~Q(issue_module__module_id__isnull=True) & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__deleted_at__isnull=True) ), ), } default_annotations = { key: Coalesce( - ArrayAgg( - field, - distinct=True, - filter=condition, - ), + ArrayAgg(field, distinct=True, filter=condition), Value([], output_field=ArrayField(UUIDField())), ) for key, (field, condition) in annotations_map.items() - if FIELD_MAPPER.get(key) != group_by - or FIELD_MAPPER.get(key) != sub_group_by + if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by } return queryset.annotate(**default_annotations) @@ -102,8 +104,7 @@ def issue_on_results(issues, group_by, sub_group_by): def issue_group_values(field, slug, project_id=None, filters=dict): if field == "state_id": queryset = State.objects.filter( - is_triage=False, - workspace__slug=slug, + is_triage=False, workspace__slug=slug ).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) @@ -120,9 +121,7 @@ def issue_group_values(field, slug, project_id=None, filters=dict): if field == "assignees__id": if project_id: return ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - is_active=True, + workspace__slug=slug, project_id=project_id, is_active=True ).values_list("member_id", flat=True) else: return list( @@ -131,17 +130,17 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ).values_list("member_id", flat=True) ) if field == "issue_module__module_id": - queryset = Module.objects.filter( - workspace__slug=slug, - ).values_list("id", flat=True) + queryset = Module.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: return list(queryset) + ["None"] if field == "cycle_id": - queryset = Cycle.objects.filter( - workspace__slug=slug, - ).values_list("id", flat=True) + queryset = Cycle.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: @@ -152,21 +151,9 @@ def issue_group_values(field, slug, project_id=None, filters=dict): ) return list(queryset) if field == "priority": - return [ - "low", - "medium", - "high", - "urgent", - "none", - ] + return ["low", "medium", "high", "urgent", "none"] if field == "state__group": - return [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + return ["backlog", "unstarted", "started", "completed", "cancelled"] if field == "target_date": queryset = ( Issue.issue_objects.filter(workspace__slug=slug) diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py index 89753ef1d..81de0203b 100644 --- a/apiserver/plane/utils/imports.py +++ b/apiserver/plane/utils/imports.py @@ -7,9 +7,7 @@ def import_submodules(context, root_module, path): Import all submodules and register them in the ``context`` namespace. >>> import_submodules(locals(), __name__, __path__) """ - for loader, module_name, is_pkg in pkgutil.walk_packages( - path, root_module + "." - ): + for loader, module_name, is_pkg in pkgutil.walk_packages(path, root_module + "."): # this causes a Runtime error with model conflicts # module = loader.find_module(module_name).load_module(module_name) module = __import__(module_name, globals(), locals(), ["__name__"]) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 713276d0c..1c9619890 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -22,9 +22,7 @@ def filter_valid_uuids(uuid_list): # Get the 2_weeks, 3_months -def string_date_filter( - issue_filter, duration, subsequent, term, date_filter, offset -): +def string_date_filter(issue_filter, duration, subsequent, term, date_filter, offset): now = timezone.now().date() if term == "months": if subsequent == "after": @@ -89,9 +87,7 @@ def date_filter(issue_filter, date_term, queries): def filter_state(params, issue_filter, method, prefix=""): if method == "GET": - states = [ - item for item in params.get("state").split(",") if item != "null" - ] + states = [item for item in params.get("state").split(",") if item != "null"] states = filter_valid_uuids(states) if len(states) and "" not in states: issue_filter[f"{prefix}state__in"] = states @@ -108,9 +104,7 @@ def filter_state(params, issue_filter, method, prefix=""): def filter_state_group(params, issue_filter, method, prefix=""): if method == "GET": state_group = [ - item - for item in params.get("state_group").split(",") - if item != "null" + item for item in params.get("state_group").split(",") if item != "null" ] if len(state_group) and "" not in state_group: issue_filter[f"{prefix}state__group__in"] = state_group @@ -127,9 +121,7 @@ def filter_state_group(params, issue_filter, method, prefix=""): def filter_estimate_point(params, issue_filter, method, prefix=""): if method == "GET": estimate_points = [ - item - for item in params.get("estimate_point").split(",") - if item != "null" + item for item in params.get("estimate_point").split(",") if item != "null" ] if len(estimate_points) and "" not in estimate_points: issue_filter[f"{prefix}estimate_point__in"] = estimate_points @@ -139,18 +131,14 @@ def filter_estimate_point(params, issue_filter, method, prefix=""): and len(params.get("estimate_point")) and params.get("estimate_point") != "null" ): - issue_filter[f"{prefix}estimate_point__in"] = params.get( - "estimate_point" - ) + issue_filter[f"{prefix}estimate_point__in"] = params.get("estimate_point") return issue_filter def filter_priority(params, issue_filter, method, prefix=""): if method == "GET": priorities = [ - item - for item in params.get("priority").split(",") - if item != "null" + item for item in params.get("priority").split(",") if item != "null" ] if len(priorities) and "" not in priorities: issue_filter[f"{prefix}priority__in"] = priorities @@ -166,9 +154,7 @@ def filter_priority(params, issue_filter, method, prefix=""): def filter_parent(params, issue_filter, method, prefix=""): if method == "GET": - parents = [ - item for item in params.get("parent").split(",") if item != "null" - ] + parents = [item for item in params.get("parent").split(",") if item != "null"] if "None" in parents: issue_filter[f"{prefix}parent__isnull"] = True parents = filter_valid_uuids(parents) @@ -186,9 +172,7 @@ def filter_parent(params, issue_filter, method, prefix=""): def filter_labels(params, issue_filter, method, prefix=""): if method == "GET": - labels = [ - item for item in params.get("labels").split(",") if item != "null" - ] + labels = [item for item in params.get("labels").split(",") if item != "null"] if "None" in labels: issue_filter[f"{prefix}labels__isnull"] = True labels = filter_valid_uuids(labels) @@ -201,15 +185,14 @@ def filter_labels(params, issue_filter, method, prefix=""): and params.get("labels") != "null" ): issue_filter[f"{prefix}labels__in"] = params.get("labels") + issue_filter[f"{prefix}label_issue__deleted_at__isnull"] = True return issue_filter def filter_assignees(params, issue_filter, method, prefix=""): if method == "GET": assignees = [ - item - for item in params.get("assignees").split(",") - if item != "null" + item for item in params.get("assignees").split(",") if item != "null" ] if "None" in assignees: issue_filter[f"{prefix}assignees__isnull"] = True @@ -223,15 +206,14 @@ def filter_assignees(params, issue_filter, method, prefix=""): and params.get("assignees") != "null" ): issue_filter[f"{prefix}assignees__in"] = params.get("assignees") + issue_filter[f"{prefix}issue_assignee__deleted_at__isnull"] = True return issue_filter def filter_mentions(params, issue_filter, method, prefix=""): if method == "GET": mentions = [ - item - for item in params.get("mentions").split(",") - if item != "null" + item for item in params.get("mentions").split(",") if item != "null" ] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: @@ -251,9 +233,7 @@ def filter_mentions(params, issue_filter, method, prefix=""): def filter_created_by(params, issue_filter, method, prefix=""): if method == "GET": created_bys = [ - item - for item in params.get("created_by").split(",") - if item != "null" + item for item in params.get("created_by").split(",") if item != "null" ] if "None" in created_bys: issue_filter[f"{prefix}created_by__isnull"] = True @@ -354,9 +334,7 @@ def filter_completed_at(params, issue_filter, method, prefix=""): queries=completed_ats, ) else: - if params.get("completed_at", None) and len( - params.get("completed_at") - ): + if params.get("completed_at", None) and len(params.get("completed_at")): date_filter( issue_filter=issue_filter, date_term=f"{prefix}completed_at__date", @@ -379,9 +357,7 @@ def filter_issue_state_type(params, issue_filter, method, prefix=""): def filter_project(params, issue_filter, method, prefix=""): if method == "GET": - projects = [ - item for item in params.get("project").split(",") if item != "null" - ] + projects = [item for item in params.get("project").split(",") if item != "null"] projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: issue_filter[f"{prefix}project__in"] = projects @@ -397,9 +373,7 @@ def filter_project(params, issue_filter, method, prefix=""): def filter_cycle(params, issue_filter, method, prefix=""): if method == "GET": - cycles = [ - item for item in params.get("cycle").split(",") if item != "null" - ] + cycles = [item for item in params.get("cycle").split(",") if item != "null"] if "None" in cycles: issue_filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True cycles = filter_valid_uuids(cycles) @@ -412,14 +386,13 @@ def filter_cycle(params, issue_filter, method, prefix=""): and params.get("cycle") != "null" ): issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle") + issue_filter[f"{prefix}issue_cycle__deleted_at__isnull"] = True return issue_filter def filter_module(params, issue_filter, method, prefix=""): if method == "GET": - modules = [ - item for item in params.get("module").split(",") if item != "null" - ] + modules = [item for item in params.get("module").split(",") if item != "null"] if "None" in modules: issue_filter[f"{prefix}issue_module__module_id__isnull"] = True modules = filter_valid_uuids(modules) @@ -431,8 +404,26 @@ def filter_module(params, issue_filter, method, prefix=""): and len(params.get("module")) and params.get("module") != "null" ): - issue_filter[f"{prefix}issue_module__module_id__in"] = params.get( - "module" + issue_filter[f"{prefix}issue_module__module_id__in"] = params.get("module") + issue_filter[f"{prefix}issue_module__deleted_at__isnull"] = True + return issue_filter + + +def filter_intake_status(params, issue_filter, method, prefix=""): + if method == "GET": + status = [ + item for item in params.get("intake_status").split(",") if item != "null" + ] + if len(status) and "" not in status: + issue_filter[f"{prefix}issue_intake__status__in"] = status + else: + if ( + params.get("intake_status", None) + and len(params.get("intake_status")) + and params.get("intake_status") != "null" + ): + issue_filter[f"{prefix}issue_intake__status__in"] = params.get( + "inbox_status" ) return issue_filter @@ -440,19 +431,17 @@ def filter_module(params, issue_filter, method, prefix=""): def filter_inbox_status(params, issue_filter, method, prefix=""): if method == "GET": status = [ - item - for item in params.get("inbox_status").split(",") - if item != "null" + item for item in params.get("inbox_status").split(",") if item != "null" ] if len(status) and "" not in status: - issue_filter[f"{prefix}issue_inbox__status__in"] = status + issue_filter[f"{prefix}issue_intake__status__in"] = status else: if ( params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != "null" ): - issue_filter[f"{prefix}issue_inbox__status__in"] = params.get( + issue_filter[f"{prefix}issue_intake__status__in"] = params.get( "inbox_status" ) return issue_filter @@ -473,23 +462,19 @@ def filter_sub_issue_toggle(params, issue_filter, method, prefix=""): def filter_subscribed_issues(params, issue_filter, method, prefix=""): if method == "GET": subscribers = [ - item - for item in params.get("subscriber").split(",") - if item != "null" + item for item in params.get("subscriber").split(",") if item != "null" ] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: - issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( - subscribers - ) + issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = subscribers else: if ( params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != "null" ): - issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( - params.get("subscriber") + issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get( + "subscriber" ) return issue_filter @@ -505,9 +490,7 @@ def filter_start_target_date_issues(params, issue_filter, method, prefix=""): def filter_logged_by(params, issue_filter, method, prefix=""): if method == "GET": logged_bys = [ - item - for item in params.get("logged_by").split(",") - if item != "null" + item for item in params.get("logged_by").split(",") if item != "null" ] if "None" in logged_bys: issue_filter[f"{prefix}logged_by__isnull"] = True @@ -548,6 +531,7 @@ def issue_filters(query_params, method, prefix=""): "project": filter_project, "cycle": filter_cycle, "module": filter_module, + "intake_status": filter_intake_status, "inbox_status": filter_inbox_status, "sub_issue": filter_sub_issue_toggle, "subscriber": filter_subscribed_issues, diff --git a/apiserver/plane/utils/issue_relation_mapper.py b/apiserver/plane/utils/issue_relation_mapper.py new file mode 100644 index 000000000..f3188eb26 --- /dev/null +++ b/apiserver/plane/utils/issue_relation_mapper.py @@ -0,0 +1,24 @@ +def get_inverse_relation(relation_type): + relation_mapping = { + "start_after": "start_before", + "finish_after": "finish_before", + "blocked_by": "blocking", + "blocking": "blocked_by", + "start_before": "start_after", + "finish_before": "finish_after", + } + return relation_mapping.get(relation_type, relation_type) + + +def get_actual_relation(relation_type): + # This function is used to get the actual relation type which is store in database + actual_relation = { + "start_after": "start_before", + "finish_after": "finish_before", + "blocking": "blocked_by", + "blocked_by": "blocked_by", + "start_before": "start_before", + "finish_before": "finish_before", + } + + return actual_relation.get(relation_type, relation_type) diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index 74d1e8019..1e7543d88 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -17,6 +17,4 @@ def search_issues(query, queryset): q |= Q(**{"sequence_id": sequence_id}) else: q |= Q(**{f"{field}__icontains": query}) - return queryset.filter( - q, - ).distinct() + return queryset.filter(q).distinct() diff --git a/apiserver/plane/utils/order_queryset.py b/apiserver/plane/utils/order_queryset.py index 920e5b1e0..174637b74 100644 --- a/apiserver/plane/utils/order_queryset.py +++ b/apiserver/plane/utils/order_queryset.py @@ -1,20 +1,8 @@ -from django.db.models import ( - Case, - CharField, - Min, - Value, - When, -) +from django.db.models import Case, CharField, Min, Value, When # Custom ordering for priority and state PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"] -STATE_ORDER = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", -] +STATE_ORDER = ["backlog", "unstarted", "started", "completed", "cancelled"] def order_issue_queryset(issue_queryset, order_by_param="-created_at"): @@ -30,15 +18,10 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): ) ).order_by("priority_order") order_by_param = ( - "priority_order" - if order_by_param.startswith("-") - else "-priority_order" + "priority_order" if order_by_param.startswith("-") else "-priority_order" ) # State Ordering - elif order_by_param in [ - "state__group", - "-state__group", - ]: + elif order_by_param in ["state__group", "-state__group"]: state_order = ( STATE_ORDER if order_by_param in ["state__name", "state__group"] @@ -72,9 +55,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): if order_by_param.startswith("-") else order_by_param ) - ).order_by( - "-min_values" if order_by_param.startswith("-") else "min_values" - ) + ).order_by("-min_values" if order_by_param.startswith("-") else "min_values") order_by_param = ( "-min_values" if order_by_param.startswith("-") else "min_values" ) diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 65f0aa7f7..6bec093e7 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -47,9 +47,7 @@ class Cursor: try: bits = value.split(":") if len(bits) != 3: - raise ValueError( - "Cursor must be in the format 'value:offset:is_prev'" - ) + raise ValueError("Cursor must be in the format 'value:offset:is_prev'") value = float(bits[0]) if "." in bits[0] else int(bits[0]) return cls(value, int(bits[1]), bool(int(bits[2]))) @@ -150,7 +148,6 @@ class OffsetPaginator: raise BadPaginationError("Pagination offset cannot be negative") results = queryset[offset:stop] - if cursor.value != limit: results = results[-(limit + 1) :] @@ -186,7 +183,6 @@ class OffsetPaginator: class GroupedOffsetPaginator(OffsetPaginator): - # Field mappers - list m2m fields here FIELD_MAPPER = { "labels__id": "label_ids", @@ -249,18 +245,14 @@ class GroupedOffsetPaginator(OffsetPaginator): nulls_last=True ) # order by desc if desc is set if self.desc - else F(*self.key).asc( - nulls_last=True - ) # Order by asc if set + else F(*self.key).asc(nulls_last=True) # Order by asc if set ), F("created_at").desc(), ), ) ) # Filter the results by row number - results = queryset.filter( - row_number__gt=offset, row_number__lt=stop - ).order_by( + results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( ( F(*self.key).desc(nulls_last=True) if self.desc @@ -271,19 +263,11 @@ class GroupedOffsetPaginator(OffsetPaginator): # Adjust cursors based on the grouped results for pagination next_cursor = Cursor( - limit, - page + 1, - False, - queryset.filter(row_number__gte=stop).exists(), + limit, page + 1, False, queryset.filter(row_number__gte=stop).exists() ) # Add previous cursors - prev_cursor = Cursor( - limit, - page - 1, - True, - page > 0, - ) + prev_cursor = Cursor(limit, page - 1, True, page > 0) # Count the queryset count = queryset.count() @@ -293,13 +277,7 @@ class GroupedOffsetPaginator(OffsetPaginator): if results: max_hits = math.ceil( queryset.values(self.group_by_field_name) - .annotate( - count=Count( - "id", - filter=self.count_filter, - distinct=True, - ) - ) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) .order_by("-count")[0]["count"] / limit ) @@ -317,13 +295,7 @@ class GroupedOffsetPaginator(OffsetPaginator): # Get total items for each group return ( self.queryset.values(self.group_by_field_name) - .annotate( - count=Count( - "id", - filter=self.count_filter, - distinct=True, - ) - ) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) .order_by() ) @@ -332,9 +304,7 @@ class GroupedOffsetPaginator(OffsetPaginator): total_group_dict = {} for group in self.__get_total_queryset(): total_group_dict[str(group.get(self.group_by_field_name))] = ( - total_group_dict.get( - str(group.get(self.group_by_field_name)), 0 - ) + total_group_dict.get(str(group.get(self.group_by_field_name)), 0) + (1 if group.get("count") == 0 else group.get("count")) ) return total_group_dict @@ -500,9 +470,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): ) # Filter the results - results = queryset.filter( - row_number__gt=offset, row_number__lt=stop - ).order_by( + results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( ( F(*self.key).desc(nulls_last=True) if self.desc @@ -513,19 +481,11 @@ class SubGroupedOffsetPaginator(OffsetPaginator): # Adjust cursors based on the grouped results for pagination next_cursor = Cursor( - limit, - page + 1, - False, - queryset.filter(row_number__gte=stop).exists(), + limit, page + 1, False, queryset.filter(row_number__gte=stop).exists() ) # Add previous cursors - prev_cursor = Cursor( - limit, - page - 1, - True, - page > 0, - ) + prev_cursor = Cursor(limit, page - 1, True, page > 0) # Count the queryset count = queryset.count() @@ -535,13 +495,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): if results: max_hits = math.ceil( queryset.values(self.group_by_field_name) - .annotate( - count=Count( - "id", - filter=self.count_filter, - distinct=True, - ) - ) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) .order_by("-count")[0]["count"] / limit ) @@ -560,29 +514,17 @@ class SubGroupedOffsetPaginator(OffsetPaginator): return ( self.queryset.order_by(self.group_by_field_name) .values(self.group_by_field_name) - .annotate( - count=Count( - "id", - filter=self.count_filter, - distinct=True, - ) - ) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) .distinct() ) def __get_subgroup_total_queryset(self): # Get subgroup totals return ( - self.queryset.values( - self.group_by_field_name, self.sub_group_by_field_name - ) - .annotate( - count=Count("id", filter=self.count_filter, distinct=True) - ) + self.queryset.values(self.group_by_field_name, self.sub_group_by_field_name) + .annotate(count=Count("id", filter=self.count_filter, distinct=True)) .order_by() - .values( - self.group_by_field_name, self.sub_group_by_field_name, "count" - ) + .values(self.group_by_field_name, self.sub_group_by_field_name, "count") ) def __get_total_dict(self): @@ -591,9 +533,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): total_sub_group_dict = {} for group in self.__get_group_total_queryset(): total_group_dict[str(group.get(self.group_by_field_name))] = ( - total_group_dict.get( - str(group.get(self.group_by_field_name)), 0 - ) + total_group_dict.get(str(group.get(self.group_by_field_name)), 0) + (1 if group.get("count") == 0 else group.get("count")) ) @@ -626,9 +566,9 @@ class SubGroupedOffsetPaginator(OffsetPaginator): "results": { str(sub_group): { "results": [], - "total_results": total_sub_group_dict.get( - str(group) - ).get(str(sub_group), 0), + "total_results": total_sub_group_dict.get(str(group)).get( + str(sub_group), 0 + ), } for sub_group in total_sub_group_dict.get(str(group), []) }, @@ -668,8 +608,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): if ( group_value in processed_results - and sub_group_value - in processed_results[str(group_value)]["results"] + and sub_group_value in processed_results[str(group_value)]["results"] ): if self.group_by_field_name in self.FIELD_MAPPER: # for multi grouper @@ -678,17 +617,15 @@ class SubGroupedOffsetPaginator(OffsetPaginator): [] if "None" in group_ids else group_ids ) if self.sub_group_by_field_name in self.FIELD_MAPPER: - sub_group_ids = list( - result_sub_group_mapping[str(result_id)] - ) + sub_group_ids = list(result_sub_group_mapping[str(result_id)]) # for multi groups - result[ - self.FIELD_MAPPER.get(self.sub_group_by_field_name) - ] = ([] if "None" in sub_group_ids else sub_group_ids) + result[self.FIELD_MAPPER.get(self.sub_group_by_field_name)] = ( + [] if "None" in sub_group_ids else sub_group_ids + ) # If a result belongs to multiple groups, add it to each group - processed_results[str(group_value)]["results"][ - str(sub_group_value) - ]["results"].append(result) + processed_results[str(group_value)]["results"][str(sub_group_value)][ + "results" + ].append(result) return processed_results @@ -761,12 +698,11 @@ class BasePaginator: ): """Paginate the request""" per_page = self.get_per_page(request, default_per_page, max_per_page) - # Convert the cursor value to integer and float from string input_cursor = None try: input_cursor = cursor_cls.from_string( - request.GET.get(self.cursor_name, f"{per_page}:0:0"), + request.GET.get(self.cursor_name, f"{per_page}:0:0") ) except ValueError: raise ParseError(detail="Invalid cursor parameter.") @@ -781,16 +717,12 @@ class BasePaginator: paginator_kwargs["sub_group_by_field_name"] = ( sub_group_by_field_name ) - paginator_kwargs["sub_group_by_fields"] = ( - sub_group_by_fields - ) + paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields paginator = paginator_cls(**paginator_kwargs) try: - cursor_result = paginator.get_result( - limit=per_page, cursor=input_cursor - ) + cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) except BadPaginationError: raise ParseError(detail="Error in parsing") diff --git a/apiserver/plane/utils/telemetry.py b/apiserver/plane/utils/telemetry.py new file mode 100644 index 000000000..bec3d240d --- /dev/null +++ b/apiserver/plane/utils/telemetry.py @@ -0,0 +1,58 @@ +# Python imports +import os +import atexit + +# Third party imports +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.instrumentation.django import DjangoInstrumentor + +# Global variable to track initialization +_TRACER_PROVIDER = None + + +def init_tracer(): + """Initialize OpenTelemetry with proper shutdown handling""" + global _TRACER_PROVIDER + + # If already initialized, return existing provider + if _TRACER_PROVIDER is not None: + return _TRACER_PROVIDER + + # Configure the tracer provider + service_name = os.environ.get("SERVICE_NAME", "plane-ce-api") + resource = Resource.create({"service.name": service_name}) + tracer_provider = TracerProvider(resource=resource) + + # Set as global tracer provider + trace.set_tracer_provider(tracer_provider) + + # Configure the OTLP exporter + otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so") + otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint) + span_processor = BatchSpanProcessor(otlp_exporter) + tracer_provider.add_span_processor(span_processor) + + # Initialize Django instrumentation + DjangoInstrumentor().instrument() + + # Store provider globally + _TRACER_PROVIDER = tracer_provider + + # Register shutdown handler + atexit.register(shutdown_tracer) + + return tracer_provider + + +def shutdown_tracer(): + """Shutdown OpenTelemetry tracers and processors""" + global _TRACER_PROVIDER + + if _TRACER_PROVIDER is not None: + if hasattr(_TRACER_PROVIDER, "shutdown"): + _TRACER_PROVIDER.shutdown() + _TRACER_PROVIDER = None diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py index c946cfb27..550abfe99 100644 --- a/apiserver/plane/utils/user_timezone_converter.py +++ b/apiserver/plane/utils/user_timezone_converter.py @@ -1,5 +1,6 @@ import pytz + def user_timezone_converter(queryset, datetime_fields, user_timezone): # Create a timezone object for the user's timezone user_tz = pytz.timezone(user_timezone) diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py index 24a3e7b57..512d4a258 100644 --- a/apiserver/plane/web/urls.py +++ b/apiserver/plane/web/urls.py @@ -1,6 +1,4 @@ from django.urls import path from django.views.generic import TemplateView -urlpatterns = [ - path("about/", TemplateView.as_view(template_name="about.html")) -] +urlpatterns = [path("about/", TemplateView.as_view(template_name="about.html"))] diff --git a/apiserver/pyproject.toml b/apiserver/pyproject.toml index a6c07b855..4292580a8 100644 --- a/apiserver/pyproject.toml +++ b/apiserver/pyproject.toml @@ -1,25 +1,96 @@ -[tool.black] -line-length = 79 -target-version = ['py36'] -include = '\.pyi?$' -exclude = ''' - /( - \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - | venv - )/ -''' +[project] +name = "Plane" +version = "0.24.0" +description = "Open-source project management that unlocks customer value" [tool.ruff] -line-length = 79 +# Exclude a variety of commonly ignored directories. exclude = [ - "**/__init__.py", + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "**/migrations/*", ] +# Same as Black. +line-length = 88 +indent-width = 4 + +[tool.ruff.format] +# Use double quotes for strings. +quote-style = "double" + +# Indent with spaces, rather than tabs. +indent-style = "space" + +# Respect magic trailing commas. +skip-magic-trailing-comma = true + +# Automatically detect the appropriate line ending. +line-ending = "auto" + +[tool.ruff.lint] +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.lint.pep8-naming] +# Allow lowercase variables like "id" +classmethod-decorators = ["classmethod", "validator", "root_validator"] + +[tool.ruff.lint.per-file-ignores] +# Ignore specific rules for tests +"tests/*" = ["E402", "F401", "F811"] +# Ignore imported but unused in __init__.py files +"__init__.py" = ["F401"] + +[tool.ruff.lint.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 + +[tool.ruff.lint.isort] +combine-as-imports = true +detect-same-package = true +force-wrap-aliases = true +known-first-party = ["plane"] +known-third-party = ["rest_framework"] +relative-imports-order = "closest-to-furthest" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "parents" + +[tool.ruff.lint.pycodestyle] +ignore-overlong-task-comments = true +max-doc-length = 88 + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.pylint] +max-args = 8 +max-statements = 50 diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index fbe6680d4..854ab95f9 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -63,7 +63,7 @@ pytz==2024.1 # jwt PyJWT==2.8.0 # OpenTelemetry -opentelemetry-api==1.27.0 -opentelemetry-sdk==1.27.0 -opentelemetry-instrumentation-django==0.48b0 -opentelemetry-exporter-otlp==1.27.0 \ No newline at end of file +opentelemetry-api==1.28.1 +opentelemetry-sdk==1.28.1 +opentelemetry-instrumentation-django==0.49b1 +opentelemetry-exporter-otlp==1.28.1 \ No newline at end of file diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 635cb88f8..fe47e625f 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -34,7 +34,7 @@ x-app-env: &app-env - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS - USE_MINIO=${USE_MINIO:-1} - - AWS_REGION=${AWS_REGION:-""} + - AWS_REGION=${AWS_REGION:-} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} diff --git a/live/package.json b/live/package.json index 07d8a053f..a4fed4434 100644 --- a/live/package.json +++ b/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "0.23.1", + "version": "0.24.0", "description": "", "main": "./src/server.ts", "private": true, diff --git a/live/src/core/extensions/index.ts b/live/src/core/extensions/index.ts index b3ac6843f..4867cad3d 100644 --- a/live/src/core/extensions/index.ts +++ b/live/src/core/extensions/index.ts @@ -1,28 +1,26 @@ // Third-party libraries import { Redis } from "ioredis"; - // Hocuspocus extensions and core import { Database } from "@hocuspocus/extension-database"; import { Extension } from "@hocuspocus/server"; import { Logger } from "@hocuspocus/extension-logger"; import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis"; - -// Core helpers and utilities +// core helpers and utilities import { manualLogger } from "@/core/helpers/logger.js"; import { getRedisUrl } from "@/core/lib/utils/redis-url.js"; - -// Core libraries +// core libraries import { fetchPageDescriptionBinary, updatePageDescription, } from "@/core/lib/page.js"; - -// Core types -import { TDocumentTypes } from "@/core/types/common.js"; - -// Plane live libraries +// plane live libraries import { fetchDocument } from "@/plane-live/lib/fetch-document.js"; import { updateDocument } from "@/plane-live/lib/update-document.js"; +// types +import { + type HocusPocusServerContext, + type TDocumentTypes, +} from "@/core/types/common.js"; export const getExtensions: () => Promise = async () => { const extensions: Extension[] = [ @@ -33,13 +31,8 @@ export const getExtensions: () => Promise = async () => { }, }), new Database({ - fetch: async ({ - documentName: pageId, - requestHeaders, - requestParameters, - }) => { - // request headers - const cookie = requestHeaders.cookie?.toString(); + fetch: async ({ context, documentName: pageId, requestParameters }) => { + const cookie = (context as HocusPocusServerContext).cookie; // query params const params = requestParameters; const documentType = params.get("documentType")?.toString() as @@ -54,7 +47,7 @@ export const getExtensions: () => Promise = async () => { fetchedData = await fetchPageDescriptionBinary( params, pageId, - cookie + cookie, ); } else { fetchedData = await fetchDocument({ @@ -71,13 +64,12 @@ export const getExtensions: () => Promise = async () => { }); }, store: async ({ + context, state, documentName: pageId, - requestHeaders, requestParameters, }) => { - // request headers - const cookie = requestHeaders.cookie?.toString(); + const cookie = (context as HocusPocusServerContext).cookie; // query params const params = requestParameters; const documentType = params.get("documentType")?.toString() as @@ -124,7 +116,7 @@ export const getExtensions: () => Promise = async () => { } manualLogger.warn( `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, - error + error, ); reject(error); }); @@ -138,12 +130,12 @@ export const getExtensions: () => Promise = async () => { } catch (error) { manualLogger.warn( `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, - error + error, ); } } else { manualLogger.warn( - "Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)" + "Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)", ); } diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index 0aa411b93..b34a8fbb2 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import { handleAuthentication } from "@/core/lib/authentication.js"; // extensions import { getExtensions } from "@/core/extensions/index.js"; +// editor types +import { TUserDetails } from "@plane/editor"; +// types +import { type HocusPocusServerContext } from "@/core/types/common.js"; export const getHocusPocusServer = async () => { const extensions = await getExtensions(); @@ -12,20 +16,40 @@ export const getHocusPocusServer = async () => { name: serverName, onAuthenticate: async ({ requestHeaders, + context, // user id used as token for authentication token, }) => { - // request headers - const cookie = requestHeaders.cookie?.toString(); + let cookie: string | undefined = undefined; + let userId: string | undefined = undefined; - if (!cookie) { - throw Error("Credentials not provided"); + // Extract cookie (fallback to request headers) and userId from token (for scenarios where + // the cookies are not passed in the request headers) + try { + const parsedToken = JSON.parse(token) as TUserDetails; + userId = parsedToken.id; + cookie = parsedToken.cookie; + } catch (error) { + // If token parsing fails, fallback to request headers + console.error("Token parsing failed, using request headers:", error); + } finally { + // If cookie is still not found, fallback to request headers + if (!cookie) { + cookie = requestHeaders.cookie?.toString(); + } } + if (!cookie || !userId) { + throw new Error("Credentials not provided"); + } + + // set cookie in context, so it can be used throughout the ws connection + (context as HocusPocusServerContext).cookie = cookie; + try { await handleAuthentication({ cookie, - token, + userId, }); } catch (error) { throw Error("Authentication unsuccessful!"); diff --git a/live/src/core/lib/authentication.ts b/live/src/core/lib/authentication.ts index ee01b0209..0f679337c 100644 --- a/live/src/core/lib/authentication.ts +++ b/live/src/core/lib/authentication.ts @@ -7,11 +7,11 @@ const userService = new UserService(); type Props = { cookie: string; - token: string; + userId: string; }; export const handleAuthentication = async (props: Props) => { - const { cookie, token } = props; + const { cookie, userId } = props; // fetch current user info let response; try { @@ -20,7 +20,7 @@ export const handleAuthentication = async (props: Props) => { manualLogger.error("Failed to fetch current user:", error); throw error; } - if (response.id !== token) { + if (response.id !== userId) { throw Error("Authentication failed: Token doesn't match the current user."); } diff --git a/live/src/core/types/common.d.ts b/live/src/core/types/common.d.ts index 3b9dff8e1..3156060ef 100644 --- a/live/src/core/types/common.d.ts +++ b/live/src/core/types/common.d.ts @@ -2,3 +2,7 @@ import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js"; export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes; + +export type HocusPocusServerContext = { + cookie: string; +}; diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index 7c1c4397d..7b9498210 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -60,12 +60,12 @@ http { proxy_pass http://space:3002/spaces/; } - location /${BUCKET_NAME}/ { + location /${BUCKET_NAME} { proxy_http_version 1.1; proxy_set_header Upgrade ${dollar}http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host ${dollar}http_host; - proxy_pass http://plane-minio:9000/uploads/; + proxy_pass http://plane-minio:9000/${BUCKET_NAME}; } } } diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index e719a0f15..819c00f21 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -68,12 +68,12 @@ http { proxy_pass http://space:3000/spaces/; } - location /${BUCKET_NAME}/ { + location /${BUCKET_NAME} { proxy_http_version 1.1; proxy_set_header Upgrade ${dollar}http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host ${dollar}http_host; - proxy_pass http://plane-minio:9000/uploads/; + proxy_pass http://plane-minio:9000/${BUCKET_NAME}; } } } diff --git a/package.json b/package.json index 372fdc937..070063609 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.23.1", + "version": "0.24.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -22,10 +22,7 @@ "devDependencies": { "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", - "turbo": "^2.1.1" - }, - "resolutions": { - "@types/react": "18.2.48" + "turbo": "^2.3.3" }, "packageManager": "yarn@1.22.22", "name": "plane" diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 66089416f..85e95bf4e 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; -export * from "./issue"; \ No newline at end of file +export * from "./issue"; +export * from "./workspace"; diff --git a/packages/constants/package.json b/packages/constants/package.json index cdf51bbaf..5271ee3b4 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.23.1", + "version": "0.24.0", "private": true, "main": "./index.ts" } diff --git a/packages/constants/workspace.ts b/packages/constants/workspace.ts new file mode 100644 index 000000000..32f36de1b --- /dev/null +++ b/packages/constants/workspace.ts @@ -0,0 +1,23 @@ +export const ORGANIZATION_SIZE = [ + "Just myself", + "2-10", + "11-50", + "51-200", + "201-500", + "500+", +]; + +export const RESTRICTED_URLS = [ + "404", + "accounts", + "api", + "create-workspace", + "error", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", +]; diff --git a/packages/editor/package.json b/packages/editor/package.json index 99c11eb5e..8471513c9 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.23.1", + "version": "0.24.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -30,13 +30,13 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "peerDependencies": { - "next": "12.3.2", - "react": "^18.2.0", - "react-dom": "18.2.0" + "react": "^18.3.1", + "react-dom": "18.3.1" }, "dependencies": { "@floating-ui/react": "^0.26.4", "@hocuspocus/provider": "^2.13.5", + "@plane/helpers": "*", "@plane/ui": "*", "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", @@ -48,7 +48,8 @@ "@tiptap/extension-placeholder": "^2.3.0", "@tiptap/extension-task-item": "^2.1.13", "@tiptap/extension-task-list": "^2.1.13", - "@tiptap/extension-text-style": "^2.1.13", + "@tiptap/extension-text-align": "^2.8.0", + "@tiptap/extension-text-style": "^2.7.1", "@tiptap/extension-underline": "^2.1.13", "@tiptap/pm": "^2.1.13", "@tiptap/react": "^2.1.13", @@ -62,6 +63,7 @@ "lowlight": "^3.0.0", "lucide-react": "^0.378.0", "prosemirror-codemark": "^0.4.2", + "prosemirror-utils": "^1.2.2", "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", @@ -76,8 +78,8 @@ "@plane/eslint-config": "*", "@plane/typescript-config": "*", "@types/node": "18.15.3", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.2.18", "postcss": "^8.4.38", "tailwind-config-custom": "*", "tsup": "^7.2.0", diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 93900700b..e3c94fa0e 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,6 +1,6 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; -import { SlashCommand } from "@/extensions"; +import { SlashCommands } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types @@ -14,7 +14,8 @@ type Props = { }; export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const extensions: Extensions = [SlashCommand()]; + const { disabledExtensions } = _props; + const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()]; return extensions; }; diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index a008d5c60..cd7d6f354 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -14,6 +14,7 @@ import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { const { + onTransaction, aiHandler, containerClassName, disabledExtensions, @@ -43,6 +44,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { // use document editor const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ + onTransaction, disabledExtensions, editorClassName, embedHandler, diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx index 35b833bf9..aa925abec 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx @@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, forwardedRef, handleEditorReady, id, @@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, id, diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 73a600e2b..8544157aa 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types"; interface IDocumentReadOnlyEditor { id: string; @@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor { displayConfig?: TDisplayConfig; editorClassName?: string; embedHandler: any; + fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; mentionHandler: { @@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, id, forwardedRef, handleEditorReady, @@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const editor = useReadOnlyEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, initialValue, diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 3e00dc2af..33f011535 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -28,6 +28,9 @@ export const EditorWrapper: React.FC = (props) => { forwardedRef, mentionHandler, onChange, + onTransaction, + handleEditorReady, + autofocus, placeholder, tabIndex, value, @@ -43,6 +46,9 @@ export const EditorWrapper: React.FC = (props) => { initialValue, mentionHandler, onChange, + onTransaction, + handleEditorReady, + autofocus, placeholder, tabIndex, value, diff --git a/packages/editor/src/core/components/editors/lite-text/editor.tsx b/packages/editor/src/core/components/editors/lite-text/editor.tsx index 924706aae..849a3c3e2 100644 --- a/packages/editor/src/core/components/editors/lite-text/editor.tsx +++ b/packages/editor/src/core/components/editors/lite-text/editor.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from "react"; +import { forwardRef, useMemo } from "react"; // components import { EditorWrapper } from "@/components/editors/editor-wrapper"; // extensions @@ -7,9 +7,15 @@ import { EnterKeyExtension } from "@/extensions"; import { EditorRefApi, ILiteTextEditor } from "@/types"; const LiteTextEditor = (props: ILiteTextEditor) => { - const { onEnterKeyPress } = props; + const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props; - const extensions = [EnterKeyExtension(onEnterKeyPress)]; + const extensions = useMemo( + () => [ + ...externalExtensions, + ...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]), + ], + [externalExtensions, disabledExtensions, onEnterKeyPress] + ); return ; }; diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index fc0911bee..e06826a28 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { containerClassName, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", + fileHandler, + forwardedRef, id, initialValue, - forwardedRef, mentionHandler, } = props; const editor = useReadOnlyEditor({ editorClassName, + fileHandler, forwardedRef, initialValue, mentionHandler, diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index fe4d2d513..87dba8b4d 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -3,29 +3,36 @@ import { forwardRef, useCallback } from "react"; import { EditorWrapper } from "@/components/editors"; import { EditorBubbleMenu } from "@/components/menus"; // extensions -import { SideMenuExtension, SlashCommand } from "@/extensions"; +import { SideMenuExtension, SlashCommands } from "@/extensions"; // types import { EditorRefApi, IRichTextEditor } from "@/types"; const RichTextEditor = (props: IRichTextEditor) => { - const { dragDropEnabled } = props; + const { + disabledExtensions, + dragDropEnabled, + bubbleMenuEnabled = true, + extensions: externalExtensions = [], + } = props; const getExtensions = useCallback(() => { - const extensions = [SlashCommand()]; - - extensions.push( + const extensions = [ + ...externalExtensions, SideMenuExtension({ aiEnabled: false, dragDropEnabled: !!dragDropEnabled, - }) - ); + }), + ]; + if (!disabledExtensions?.includes("slash-commands")) { + extensions.push(SlashCommands()); + } return extensions; - }, [dragDropEnabled]); + }, [dragDropEnabled, disabledExtensions, externalExtensions]); return ( - {(editor) => <>{editor && }} + {(editor) => <>{editor && bubbleMenuEnabled && }} ); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx new file mode 100644 index 000000000..e3ccc6cf6 --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx @@ -0,0 +1,91 @@ +import { Editor } from "@tiptap/core"; +import { AlignCenter, AlignLeft, AlignRight, LucideIcon } from "lucide-react"; +// components +import { TextAlignItem } from "@/components/menus"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { TEditorCommands } from "@/types"; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const TextAlignmentSelector: React.FC = (props) => { + const { editor, onClose } = props; + + const menuItem = TextAlignItem(editor); + + const textAlignmentOptions: { + itemKey: TEditorCommands; + renderKey: string; + icon: LucideIcon; + command: () => void; + isActive: () => boolean; + }[] = [ + { + itemKey: "text-align", + renderKey: "text-align-left", + icon: AlignLeft, + command: () => + menuItem.command({ + alignment: "left", + }), + isActive: () => + menuItem.isActive({ + alignment: "left", + }), + }, + { + itemKey: "text-align", + renderKey: "text-align-center", + icon: AlignCenter, + command: () => + menuItem.command({ + alignment: "center", + }), + isActive: () => + menuItem.isActive({ + alignment: "center", + }), + }, + { + itemKey: "text-align", + renderKey: "text-align-right", + icon: AlignRight, + command: () => + menuItem.command({ + alignment: "right", + }), + isActive: () => + menuItem.isActive({ + alignment: "right", + }), + }, + ]; + + return ( +
+ {textAlignmentOptions.map((item) => ( + + ))} +
+ ); +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx new file mode 100644 index 000000000..bc7f5a56f --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -0,0 +1,106 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Editor } from "@tiptap/react"; +import { ALargeSmall, Ban } from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { cn } from "@/helpers/common"; +import { BackgroundColorItem, TextColorItem } from "../menu-items"; + +type Props = { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +}; + +export const BubbleMenuColorSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })); + const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })); + + return ( +
+ + {isOpen && ( +
+
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/index.ts b/packages/editor/src/core/components/menus/bubble-menu/index.ts index 71a98bada..526feed3d 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/index.ts +++ b/packages/editor/src/core/components/menus/bubble-menu/index.ts @@ -1,3 +1,4 @@ +export * from "./color-selector"; export * from "./link-selector"; export * from "./node-selector"; export * from "./root"; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 20335e8ab..eaa20ed26 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,6 +1,6 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; -import { Check, Trash } from "lucide-react"; +import { Check, Link, Trash } from "lucide-react"; // helpers import { cn, isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -11,7 +11,9 @@ type Props = { setIsOpen: Dispatch>; }; -export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuLinkSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + // refs const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { @@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) }); return ( -
+
{isOpen && (
>; }; -export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { - const items: BubbleMenuItem[] = [ +export const BubbleMenuNodeSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const items: EditorMenuItem[] = [ TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), @@ -54,12 +58,11 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) setIsOpen(!isOpen); e.stopPropagation(); }} - className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5" + className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors" > {activeItem?.name} - + - {isOpen && (
{items.map((item) => ( diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index ec72f1540..18079f089 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,9 +1,9 @@ import { FC, useEffect, useState } from "react"; -import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; -import { LucideIcon } from "lucide-react"; +import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react"; // components import { BoldItem, + BubbleMenuColorSelector, BubbleMenuLinkSelector, BubbleMenuNodeSelector, CodeItem, @@ -15,35 +15,26 @@ import { import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; // helpers import { cn } from "@/helpers/common"; - -export interface BubbleMenuItem { - key: string; - name: string; - isActive: () => boolean; - command: () => void; - icon: LucideIcon; -} +// local components +import { TextAlignmentSelector } from "./alignment-selector"; type EditorBubbleMenuProps = Omit; export const EditorBubbleMenu: FC = (props: any) => { - const items: BubbleMenuItem[] = [ - ...(props.editor.isActive("code") - ? [] - : [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), - ]), - CodeItem(props.editor), - ]; + // states + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); + + const basicFormattingOptions = props.editor.isActive("code") + ? [CodeItem(props.editor)] + : [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, shouldShow: ({ state, editor }) => { const { selection } = state; - const { empty } = selection; if ( @@ -63,15 +54,11 @@ export const EditorBubbleMenu: FC = (props: any) => { onHidden: () => { setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); }, }, }; - const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); - const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); - - const [isSelecting, setIsSelecting] = useState(false); - useEffect(() => { function handleMouseDown() { function handleMouseMove() { @@ -102,54 +89,78 @@ export const EditorBubbleMenu: FC = (props: any) => { return ( - {isSelecting ? null : ( + {!isSelecting && ( <> - {!props.editor.isActive("table") && ( - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsLinkSelectorOpen(false); - }} - /> - )} - {!props.editor.isActive("code") && ( - { - setIsLinkSelectorOpen(!isLinkSelectorOpen); - setIsNodeSelectorOpen(false); - }} - /> - )} -
- {items.map((item) => ( +
+ {!props.editor.isActive("table") && ( + { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsLinkSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsColorSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + )} +
+
+ {basicFormattingOptions.map((item) => ( ))}
+ { + const editor = props.editor as Editor; + if (!editor) return; + const pos = editor.state.selection.to; + editor.commands.setTextSelection(pos ?? 0); + }} + /> )} diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index cf10081f1..5e987fca6 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -1,4 +1,3 @@ -import { Selection } from "@tiptap/pm/state"; import { Editor } from "@tiptap/react"; import { BoldIcon, @@ -6,7 +5,7 @@ import { CheckSquare, Heading2, Heading3, - QuoteIcon, + TextQuote, ImageIcon, TableIcon, ListIcon, @@ -20,12 +19,18 @@ import { Heading6, CaseSensitive, LucideIcon, + MinusSquare, + Palette, + AlignCenter, } from "lucide-react"; // helpers import { + insertHorizontalRule, insertImage, insertTableCommand, setText, + setTextAlign, + toggleBackgroundColor, toggleBlockquote, toggleBold, toggleBulletList, @@ -40,20 +45,24 @@ import { toggleOrderedList, toggleStrike, toggleTaskList, + toggleTextColor, toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands } from "@/types"; +import { TCommandWithProps, TEditorCommands } from "@/types"; -export interface EditorMenuItem { - key: TEditorCommands; +type isActiveFunction = (params?: TCommandWithProps) => boolean; +type commandFunction = (params?: TCommandWithProps) => void; + +export type EditorMenuItem = { + key: T; name: string; - isActive: () => boolean; - command: () => void; + command: commandFunction; icon: LucideIcon; -} + isActive: isActiveFunction; +}; -export const TextItem = (editor: Editor): EditorMenuItem => ({ +export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({ key: "text", name: "Text", isActive: () => editor.isActive("paragraph"), @@ -61,7 +70,7 @@ export const TextItem = (editor: Editor): EditorMenuItem => ({ icon: CaseSensitive, }); -export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({ key: "h1", name: "Heading 1", isActive: () => editor.isActive("heading", { level: 1 }), @@ -69,7 +78,7 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ icon: Heading1, }); -export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({ key: "h2", name: "Heading 2", isActive: () => editor.isActive("heading", { level: 2 }), @@ -77,7 +86,7 @@ export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ icon: Heading2, }); -export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({ key: "h3", name: "Heading 3", isActive: () => editor.isActive("heading", { level: 3 }), @@ -85,7 +94,7 @@ export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ icon: Heading3, }); -export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({ key: "h4", name: "Heading 4", isActive: () => editor.isActive("heading", { level: 4 }), @@ -93,7 +102,7 @@ export const HeadingFourItem = (editor: Editor): EditorMenuItem => ({ icon: Heading4, }); -export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({ key: "h5", name: "Heading 5", isActive: () => editor.isActive("heading", { level: 5 }), @@ -101,7 +110,7 @@ export const HeadingFiveItem = (editor: Editor): EditorMenuItem => ({ icon: Heading5, }); -export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({ +export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({ key: "h6", name: "Heading 6", isActive: () => editor.isActive("heading", { level: 6 }), @@ -109,7 +118,7 @@ export const HeadingSixItem = (editor: Editor): EditorMenuItem => ({ icon: Heading6, }); -export const BoldItem = (editor: Editor): EditorMenuItem => ({ +export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ key: "bold", name: "Bold", isActive: () => editor?.isActive("bold"), @@ -117,7 +126,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem => ({ icon: BoldIcon, }); -export const ItalicItem = (editor: Editor): EditorMenuItem => ({ +export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ key: "italic", name: "Italic", isActive: () => editor?.isActive("italic"), @@ -125,7 +134,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem => ({ icon: ItalicIcon, }); -export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ +export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ key: "underline", name: "Underline", isActive: () => editor?.isActive("underline"), @@ -133,7 +142,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ icon: UnderlineIcon, }); -export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ +export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ key: "strikethrough", name: "Strikethrough", isActive: () => editor?.isActive("strike"), @@ -141,7 +150,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ icon: StrikethroughIcon, }); -export const BulletListItem = (editor: Editor): EditorMenuItem => ({ +export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({ key: "bulleted-list", name: "Bulleted list", isActive: () => editor?.isActive("bulletList"), @@ -149,7 +158,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem => ({ icon: ListIcon, }); -export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ +export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({ key: "numbered-list", name: "Numbered list", isActive: () => editor?.isActive("orderedList"), @@ -157,7 +166,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ icon: ListOrderedIcon, }); -export const TodoListItem = (editor: Editor): EditorMenuItem => ({ +export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ key: "to-do-list", name: "To-do list", isActive: () => editor.isActive("taskItem"), @@ -165,15 +174,15 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({ icon: CheckSquare, }); -export const QuoteItem = (editor: Editor): EditorMenuItem => ({ +export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ key: "quote", name: "Quote", isActive: () => editor?.isActive("blockquote"), command: () => toggleBlockquote(editor), - icon: QuoteIcon, + icon: TextQuote, }); -export const CodeItem = (editor: Editor): EditorMenuItem => ({ +export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ key: "code", name: "Code", isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), @@ -181,7 +190,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem => ({ icon: CodeIcon, }); -export const TableItem = (editor: Editor): EditorMenuItem => ({ +export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ key: "table", name: "Table", isActive: () => editor?.isActive("table"), @@ -189,19 +198,51 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({ icon: TableIcon, }); -export const ImageItem = (editor: Editor) => +export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({ + key: "image", + name: "Image", + isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), + command: ({ savedSelection }) => + insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }), + icon: ImageIcon, +}); + +export const HorizontalRuleItem = (editor: Editor) => ({ - key: "image", - name: "Image", - isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), - command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }), - icon: ImageIcon, + key: "divider", + name: "Divider", + isActive: () => editor?.isActive("horizontalRule"), + command: () => insertHorizontalRule(editor), + icon: MinusSquare, }) as const; -export function getEditorMenuItems(editor: Editor | null) { - if (!editor) { - return []; - } +export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({ + key: "text-color", + name: "Color", + isActive: ({ color }) => editor.isActive("customColor", { color }), + command: ({ color }) => toggleTextColor(color, editor), + icon: Palette, +}); + +export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({ + key: "background-color", + name: "Background color", + isActive: ({ color }) => editor.isActive("customColor", { backgroundColor: color }), + command: ({ color }) => toggleBackgroundColor(color, editor), + icon: Palette, +}); + +export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({ + key: "text-align", + name: "Text align", + isActive: ({ alignment }) => editor.isActive({ textAlign: alignment }), + command: ({ alignment }) => setTextAlign(alignment, editor), + icon: AlignCenter, +}); + +export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { + if (!editor) return []; + return [ TextItem(editor), HeadingOneItem(editor), @@ -221,5 +262,9 @@ export function getEditorMenuItems(editor: Editor | null) { QuoteItem(editor), TableItem(editor), ImageItem(editor), + HorizontalRuleItem(editor), + TextColorItem(editor), + BackgroundColorItem(editor), + TextAlignItem(editor), ]; -} +}; diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts new file mode 100644 index 000000000..7f4f7f66f --- /dev/null +++ b/packages/editor/src/core/constants/common.ts @@ -0,0 +1,61 @@ +export const COLORS_LIST: { + key: string; + label: string; + textColor: string; + backgroundColor: string; +}[] = [ + { + key: "gray", + label: "Gray", + textColor: "var(--editor-colors-gray-text)", + backgroundColor: "var(--editor-colors-gray-background)", + }, + { + key: "peach", + label: "Peach", + textColor: "var(--editor-colors-peach-text)", + backgroundColor: "var(--editor-colors-peach-background)", + }, + { + key: "pink", + label: "Pink", + textColor: "var(--editor-colors-pink-text)", + backgroundColor: "var(--editor-colors-pink-background)", + }, + { + key: "orange", + label: "Orange", + textColor: "var(--editor-colors-orange-text)", + backgroundColor: "var(--editor-colors-orange-background)", + }, + { + key: "green", + label: "Green", + textColor: "var(--editor-colors-green-text)", + backgroundColor: "var(--editor-colors-green-background)", + }, + { + key: "light-blue", + label: "Light blue", + textColor: "var(--editor-colors-light-blue-text)", + backgroundColor: "var(--editor-colors-light-blue-background)", + }, + { + key: "dark-blue", + label: "Dark blue", + textColor: "var(--editor-colors-dark-blue-text)", + backgroundColor: "var(--editor-colors-dark-blue-background)", + }, + { + key: "purple", + label: "Purple", + textColor: "var(--editor-colors-purple-text)", + backgroundColor: "var(--editor-colors-purple-background)", + }, + // { + // key: "pink-blue-gradient", + // label: "Pink blue gradient", + // textColor: "var(--editor-colors-pink-blue-gradient-text)", + // backgroundColor: "var(--editor-colors-pink-blue-gradient-background)", + // }, +]; diff --git a/packages/editor/src/core/extensions/callout/block.tsx b/packages/editor/src/core/extensions/callout/block.tsx new file mode 100644 index 000000000..b6c6d7991 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/block.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// local components +import { CalloutBlockColorSelector } from "./color-selector"; +import { CalloutBlockLogoSelector } from "./logo-selector"; +// types +import { EAttributeNames, TCalloutBlockAttributes } from "./types"; +// utils +import { updateStoredBackgroundColor } from "./utils"; + +type Props = NodeViewProps & { + node: NodeViewProps["node"] & { + attrs: TCalloutBlockAttributes; + }; + updateAttributes: (attrs: Partial) => void; +}; + +export const CustomCalloutBlock: React.FC = (props) => { + const { editor, node, updateAttributes } = props; + // states + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + // derived values + const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor; + + return ( + + setIsEmojiPickerOpen(val)} + updateAttributes={updateAttributes} + /> + setIsColorPickerOpen((prev) => !prev)} + onSelect={(val) => { + updateAttributes({ + [EAttributeNames.BACKGROUND]: val, + }); + updateStoredBackgroundColor(val); + }} + /> + + + ); +}; diff --git a/packages/editor/src/core/extensions/callout/color-selector.tsx b/packages/editor/src/core/extensions/callout/color-selector.tsx new file mode 100644 index 000000000..489b05166 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/color-selector.tsx @@ -0,0 +1,75 @@ +import { Ban, ChevronDown } from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { cn } from "@/helpers/common"; + +type Props = { + disabled: boolean; + isOpen: boolean; + onSelect: (color: string | null) => void; + toggleDropdown: () => void; +}; + +export const CalloutBlockColorSelector: React.FC = (props) => { + const { disabled, isOpen, onSelect, toggleDropdown } = props; + + const handleColorSelect = (val: string | null) => { + onSelect(val); + toggleDropdown(); + }; + + return ( +
+
+ + {isOpen && ( +
+
+ {COLORS_LIST.map((color) => ( + +
+
+ )} +
+
+ ); +}; diff --git a/packages/editor/src/core/extensions/callout/extension-config.ts b/packages/editor/src/core/extensions/callout/extension-config.ts new file mode 100644 index 000000000..546311509 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/extension-config.ts @@ -0,0 +1,72 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { Node as NodeType } from "@tiptap/pm/model"; +import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +// types +import { EAttributeNames, TCalloutBlockAttributes } from "./types"; +// utils +import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils"; + +// Extend Tiptap's Commands interface +declare module "@tiptap/core" { + interface Commands { + calloutComponent: { + insertCallout: () => ReturnType; + }; + } +} + +export const CustomCalloutExtensionConfig = Node.create({ + name: "calloutComponent", + group: "block", + content: "block+", + + addAttributes() { + const attributes = { + // Reduce instead of map to accumulate the attributes directly into an object + ...Object.values(EAttributeNames).reduce((acc, value) => { + acc[value] = { + default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value], + }; + return acc; + }, {}), + }; + return attributes; + }, + + addStorage() { + return { + markdown: { + serialize(state: MarkdownSerializerState, node: NodeType) { + const attrs = node.attrs as TCalloutBlockAttributes; + const logoInUse = attrs["data-logo-in-use"]; + // add callout logo + if (logoInUse === "emoji") { + state.write( + `> ${attrs[\n` + ); + } else { + state.write(`> ${attrs["data-icon-name"]} icon\n`); + } + // add an empty line after the logo + state.write("> \n"); + // add '> ' before each line of the callout content + state.wrapBlock("> ", null, node, () => state.renderContent(node)); + state.closeBlock(node); + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`, + }, + ]; + }, + + // Render HTML for the callout node + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes(HTMLAttributes), 0]; + }, +}); diff --git a/packages/editor/src/core/extensions/callout/extension.tsx b/packages/editor/src/core/extensions/callout/extension.tsx new file mode 100644 index 000000000..a83964b37 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/extension.tsx @@ -0,0 +1,68 @@ +import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomCalloutBlock } from "@/extensions"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// config +import { CustomCalloutExtensionConfig } from "./extension-config"; +// utils +import { getStoredBackgroundColor, getStoredLogo } from "./utils"; + +export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({ + selectable: true, + draggable: true, + + addCommands() { + return { + insertCallout: + () => + ({ commands }) => { + // get stored logo values and background color from the local storage + const storedLogoValues = getStoredLogo(); + const storedBackgroundValue = getStoredBackgroundColor(); + + return commands.insertContent({ + type: this.name, + content: [ + { + type: "paragraph", + }, + ], + attrs: { + ...storedLogoValues, + "data-background": storedBackgroundValue, + }, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { $from, empty } = editor.state.selection; + try { + const isParentNodeCallout: Predicate = (node) => node.type === this.type; + const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout); + // Check if selection is empty and at the beginning of the callout + if (empty && parentNodeDetails) { + const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1; + if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) { + editor.commands.setTextSelection(parentNodeDetails.pos - 1); + return true; + } + } + } catch (error) { + console.error("Error in performing backspace action on callout", error); + } + return false; // Allow the default behavior if conditions are not met + }, + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomCalloutBlock); + }, +}); diff --git a/packages/editor/src/core/extensions/callout/index.ts b/packages/editor/src/core/extensions/callout/index.ts new file mode 100644 index 000000000..57915d313 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/index.ts @@ -0,0 +1,3 @@ +export * from "./block"; +export * from "./extension"; +export * from "./read-only-extension"; diff --git a/packages/editor/src/core/extensions/callout/logo-selector.tsx b/packages/editor/src/core/extensions/callout/logo-selector.tsx new file mode 100644 index 000000000..4e9f966af --- /dev/null +++ b/packages/editor/src/core/extensions/callout/logo-selector.tsx @@ -0,0 +1,97 @@ +// plane helpers +import { convertHexEmojiToDecimal } from "@plane/helpers"; +// plane ui +import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { TCalloutBlockAttributes } from "./types"; +// utils +import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils"; + +type Props = { + blockAttributes: TCalloutBlockAttributes; + disabled: boolean; + handleOpen: (val: boolean) => void; + isOpen: boolean; + updateAttributes: (attrs: Partial) => void; +}; + +export const CalloutBlockLogoSelector: React.FC = (props) => { + const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props; + + const logoValue: TEmojiLogoProps = { + in_use: blockAttributes["data-logo-in-use"], + icon: { + color: blockAttributes["data-icon-color"], + name: blockAttributes["data-icon-name"], + }, + emoji: { + value: blockAttributes["data-emoji-unicode"]?.toString(), + url: blockAttributes["data-emoji-url"], + }, + }; + + return ( +
+ } + onChange={(val) => { + // construct the new logo value based on the type of value + let newLogoValue: Partial = {}; + let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = { + in_use: "emoji", + emoji: { + value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"], + url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"], + }, + }; + if (val.type === "emoji") { + newLogoValue = { + "data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified), + "data-emoji-url": val.value.imageUrl, + }; + newLogoValueToStoreInLocalStorage = { + in_use: "emoji", + emoji: { + value: convertHexEmojiToDecimal(val.value.unified), + url: val.value.imageUrl, + }, + }; + } else if (val.type === "icon") { + newLogoValue = { + "data-icon-name": val.value.name, + "data-icon-color": val.value.color, + }; + newLogoValueToStoreInLocalStorage = { + in_use: "icon", + icon: { + name: val.value.name, + color: val.value.color, + }, + }; + } + // update node attributes + updateAttributes({ + "data-logo-in-use": val.type, + ...newLogoValue, + }); + // update stored logo in local storage + updateStoredLogo(newLogoValueToStoreInLocalStorage); + handleOpen(false); + }} + defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined} + defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} + disabled={disabled} + searchDisabled + /> +
+ ); +}; diff --git a/packages/editor/src/core/extensions/callout/read-only-extension.tsx b/packages/editor/src/core/extensions/callout/read-only-extension.tsx new file mode 100644 index 000000000..ad7dbd50d --- /dev/null +++ b/packages/editor/src/core/extensions/callout/read-only-extension.tsx @@ -0,0 +1,14 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomCalloutBlock } from "@/extensions"; +// config +import { CustomCalloutExtensionConfig } from "./extension-config"; + +export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({ + selectable: false, + draggable: false, + + addNodeView() { + return ReactNodeViewRenderer(CustomCalloutBlock); + }, +}); diff --git a/packages/editor/src/core/extensions/callout/types.ts b/packages/editor/src/core/extensions/callout/types.ts new file mode 100644 index 000000000..17c55d9e5 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/types.ts @@ -0,0 +1,26 @@ +export enum EAttributeNames { + ICON_COLOR = "data-icon-color", + ICON_NAME = "data-icon-name", + EMOJI_UNICODE = "data-emoji-unicode", + EMOJI_URL = "data-emoji-url", + LOGO_IN_USE = "data-logo-in-use", + BACKGROUND = "data-background", + BLOCK_TYPE = "data-block-type", +} + +export type TCalloutBlockIconAttributes = { + [EAttributeNames.ICON_COLOR]: string | undefined; + [EAttributeNames.ICON_NAME]: string | undefined; +}; + +export type TCalloutBlockEmojiAttributes = { + [EAttributeNames.EMOJI_UNICODE]: string | undefined; + [EAttributeNames.EMOJI_URL]: string | undefined; +}; + +export type TCalloutBlockAttributes = { + [EAttributeNames.LOGO_IN_USE]: "emoji" | "icon"; + [EAttributeNames.BACKGROUND]: string; + [EAttributeNames.BLOCK_TYPE]: "callout-component"; +} & TCalloutBlockIconAttributes & + TCalloutBlockEmojiAttributes; diff --git a/packages/editor/src/core/extensions/callout/utils.ts b/packages/editor/src/core/extensions/callout/utils.ts new file mode 100644 index 000000000..c450cbdd2 --- /dev/null +++ b/packages/editor/src/core/extensions/callout/utils.ts @@ -0,0 +1,85 @@ +// plane helpers +import { sanitizeHTML } from "@plane/helpers"; +// plane ui +import { TEmojiLogoProps } from "@plane/ui"; +// types +import { + EAttributeNames, + TCalloutBlockAttributes, + TCalloutBlockEmojiAttributes, + TCalloutBlockIconAttributes, +} from "./types"; + +export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = { + "data-logo-in-use": "emoji", + "data-icon-color": null, + "data-icon-name": null, + "data-emoji-unicode": "128161", + "data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png", + "data-background": null, + "data-block-type": "callout-component", +}; + +type TStoredLogoValue = Pick & + (TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes); + +// function to get the stored logo from local storage +export const getStoredLogo = (): TStoredLogoValue => { + const fallBackValues: TStoredLogoValue = { + "data-logo-in-use": "emoji", + "data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"], + "data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"], + }; + + if (typeof window !== "undefined") { + const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo")); + if (storedData) { + let parsedData: TEmojiLogoProps; + try { + parsedData = JSON.parse(storedData); + } catch (error) { + console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error); + localStorage.removeItem("editor-calloutComponent-logo"); + return fallBackValues; + } + if (parsedData.in_use === "emoji" && parsedData.emoji?.value) { + return { + "data-logo-in-use": "emoji", + "data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"], + "data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"], + }; + } + if (parsedData.in_use === "icon" && parsedData.icon?.name) { + return { + "data-logo-in-use": "icon", + "data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"], + "data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"], + }; + } + } + } + // fallback values + return fallBackValues; +}; +// function to update the stored logo on local storage +export const updateStoredLogo = (value: TEmojiLogoProps): void => { + if (typeof window === "undefined") return; + localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value)); +}; +// function to get the stored background color from local storage +export const getStoredBackgroundColor = (): string | null => { + if (typeof window !== "undefined") { + return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background")); + } + return null; +}; +// function to update the stored background color on local storage +export const updateStoredBackgroundColor = (value: string | null): void => { + if (typeof window === "undefined") return; + if (value === null) { + localStorage.removeItem("editor-calloutComponent-background"); + return; + } else { + localStorage.setItem("editor-calloutComponent-background", value); + } +}; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 1cedd5139..075d90f2d 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -16,6 +16,9 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props import { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; +import { CustomTextAlignExtension } from "./text-align"; +import { CustomCalloutExtensionConfig } from "./callout/extension-config"; +import { CustomColorExtension } from "./custom-color"; export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ @@ -43,7 +46,7 @@ export const CoreEditorExtensionsWithoutProps = [ CustomQuoteExtension, CustomHorizontalRule.configure({ HTMLAttributes: { - class: "my-4 border-custom-border-400", + class: "py-4 border-custom-border-400", }, }), CustomLinkExtension.configure({ @@ -83,6 +86,9 @@ export const CoreEditorExtensionsWithoutProps = [ TableCell, TableRow, CustomMentionWithoutProps(), + CustomTextAlignExtension, + CustomCalloutExtensionConfig, + CustomColorExtension, ]; export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts new file mode 100644 index 000000000..dc966816c --- /dev/null +++ b/packages/editor/src/core/extensions/custom-color.ts @@ -0,0 +1,133 @@ +import { Mark, mergeAttributes } from "@tiptap/core"; +// constants +import { COLORS_LIST } from "@/constants/common"; + +declare module "@tiptap/core" { + interface Commands { + color: { + /** + * Set the text color + * @param {string} color The color to set + * @example editor.commands.setTextColor('red') + */ + setTextColor: (color: string) => ReturnType; + + /** + * Unset the text color + * @example editor.commands.unsetTextColor() + */ + unsetTextColor: () => ReturnType; + /** + * Set the background color + * @param {string} backgroundColor The color to set + * @example editor.commands.setBackgroundColor('red') + */ + setBackgroundColor: (backgroundColor: string) => ReturnType; + + /** + * Unset the background color + * @example editor.commands.unsetBackgroundColorColor() + */ + unsetBackgroundColor: () => ReturnType; + }; + } +} + +export const CustomColorExtension = Mark.create({ + name: "customColor", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + color: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"), + renderHTML: (attributes: { color: string }) => { + const { color } = attributes; + if (!color) { + return {}; + } + + let elementAttributes: Record = { + "data-text-color": color, + }; + + if (!COLORS_LIST.find((c) => c.key === color)) { + elementAttributes = { + ...elementAttributes, + style: `color: ${color}`, + }; + } + + return elementAttributes; + }, + }, + backgroundColor: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"), + renderHTML: (attributes: { backgroundColor: string }) => { + const { backgroundColor } = attributes; + if (!backgroundColor) { + return {}; + } + + let elementAttributes: Record = { + "data-background-color": backgroundColor, + }; + + if (!COLORS_LIST.find((c) => c.key === backgroundColor)) { + elementAttributes = { + ...elementAttributes, + style: `background-color: ${backgroundColor}`, + }; + } + + return elementAttributes; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "span", + getAttrs: (node) => node.getAttribute("data-text-color") && null, + }, + { + tag: "span", + getAttrs: (node) => node.getAttribute("data-background-color") && null, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setTextColor: + (color: string) => + ({ chain }) => + chain().setMark(this.name, { color }).run(), + unsetTextColor: + () => + ({ chain }) => + chain().setMark(this.name, { color: null }).run(), + setBackgroundColor: + (backgroundColor: string) => + ({ chain }) => + chain().setMark(this.name, { backgroundColor }).run(), + unsetBackgroundColor: + () => + ({ chain }) => + chain().setMark(this.name, { backgroundColor: null }).run(), + }; + }, +}); diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index c7008db00..f1a85ab1b 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; import { NodeSelection } from "@tiptap/pm/state"; // extensions -import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; // helpers import { cn } from "@/helpers/common"; @@ -37,11 +37,12 @@ const ensurePixelString = (value: Pixel | TDefault | number | undefin return value; }; -type CustomImageBlockProps = CustomImageNodeViewProps & { +type CustomImageBlockProps = CustoBaseImageNodeViewProps & { imageFromFileSystem: string; setFailedToLoadImage: (isError: boolean) => void; editorContainer: HTMLDivElement | null; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; + src: string; }; export const CustomImageBlock: React.FC = (props) => { @@ -55,9 +56,10 @@ export const CustomImageBlock: React.FC = (props) => { getPos, editor, editorContainer, + src: resolvedImageSrc, setEditorContainer, } = props; - const { src: remoteImageSrc, width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs; + const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs; // states const [size, setSize] = useState({ width: ensurePixelString(nodeWidth, "35%"), @@ -70,6 +72,8 @@ export const CustomImageBlock: React.FC = (props) => { const containerRef = useRef(null); const containerRect = useRef(null); const imageRef = useRef(null); + const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false); + const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const updateAttributesSafely = useCallback( (attributes: Partial, errorMessage: string) => { @@ -143,8 +147,9 @@ export const CustomImageBlock: React.FC = (props) => { ...prevSize, width: ensurePixelString(nodeWidth), height: ensurePixelString(nodeHeight), + aspectRatio: nodeAspectRatio, })); - }, [nodeWidth, nodeHeight]); + }, [nodeWidth, nodeHeight, nodeAspectRatio]); const handleResize = useCallback( (e: MouseEvent | TouchEvent) => { @@ -157,7 +162,7 @@ export const CustomImageBlock: React.FC = (props) => { setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); }, - [size] + [size.aspectRatio] ); const handleResizeEnd = useCallback(() => { @@ -180,11 +185,15 @@ export const CustomImageBlock: React.FC = (props) => { window.addEventListener("mousemove", handleResize); window.addEventListener("mouseup", handleResizeEnd); window.addEventListener("mouseleave", handleResizeEnd); + window.addEventListener("touchmove", handleResize); + window.addEventListener("touchend", handleResizeEnd); return () => { window.removeEventListener("mousemove", handleResize); window.removeEventListener("mouseup", handleResizeEnd); window.removeEventListener("mouseleave", handleResizeEnd); + window.removeEventListener("touchmove", handleResize); + window.removeEventListener("touchend", handleResizeEnd); }; } }, [isResizing, handleResize, handleResizeEnd]); @@ -201,13 +210,13 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete - const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; + const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageUtils = remoteImageSrc && initialResizeComplete; + const showImageUtils = resolvedImageSrc && initialResizeComplete; // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete; + const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete; // show the preview image from the file system if the remote image's src is not set - const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; + const displayedImageSrc = resolvedImageSrc || imageFromFileSystem; return (
= (props) => { ref={imageRef} src={displayedImageSrc} onLoad={handleImageLoad} - onError={(e) => { - console.error("Error loading image", e); - setFailedToLoadImage(true); + onError={async (e) => { + // for old image extension this command doesn't exist or if the image failed to load for the first time + if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { + setFailedToLoadImage(true); + return; + } + + try { + setHasErroredOnFirstLoad(true); + // this is a type error from tiptap, don't remove await until it's fixed + await editor?.commands.restoreImage?.(imgNodeSrc); + imageRef.current.src = resolvedImageSrc; + } catch { + // if the image failed to even restore, then show the error state + setFailedToLoadImage(true); + console.error("Error while loading image", e); + } finally { + setHasErroredOnFirstLoad(false); + setHasTriedRestoringImageOnce(true); + } }} width={size.width} className={cn("image-component block rounded-md", { // hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then hidden: showImageLoader, "read-only-image": !editor.isEditable, - "blur-sm opacity-80 loading-image": !remoteImageSrc, + "blur-sm opacity-80 loading-image": !resolvedImageSrc, })} style={{ width: size.width, @@ -251,14 +277,14 @@ export const CustomImageBlock: React.FC = (props) => { "absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity" } image={{ - src: remoteImageSrc, + src: resolvedImageSrc, aspectRatio: size.aspectRatio, height: size.height, width: size.width, }} /> )} - {selected && displayedImageSrc === remoteImageSrc && ( + {selected && displayedImageSrc === resolvedImageSrc && (
)} {showImageResizer && ( @@ -282,6 +308,7 @@ export const CustomImageBlock: React.FC = (props) => { } )} onMouseDown={handleResizeStart} + onTouchStart={handleResizeStart} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index c37bcd29c..78caa87b3 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,23 +1,26 @@ import { useEffect, useRef, useState } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor, NodeViewWrapper } from "@tiptap/react"; +import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; -export type CustomImageNodeViewProps = { +export type CustoBaseImageNodeViewProps = { getPos: () => number; editor: Editor; - node: ProsemirrorNode & { + node: NodeViewProps["node"] & { attrs: ImageAttributes; }; - updateAttributes: (attrs: Record) => void; + updateAttributes: (attrs: Partial) => void; selected: boolean; }; -export const CustomImageNode = (props: CustomImageNodeViewProps) => { +export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps; + +export const CustomImageNode = (props: CustomImageNodeProps) => { const { getPos, editor, node, updateAttributes, selected } = props; + const { src: imgNodeSrc } = node.attrs; const [isUploaded, setIsUploaded] = useState(false); + const [resolvedSrc, setResolvedSrc] = useState(undefined); const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); const [failedToLoadImage, setFailedToLoadImage] = useState(false); @@ -37,14 +40,22 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { // the image is already uploaded if the image-component node has src attribute // and we need to remove the blob from our file system useEffect(() => { - const remoteImageSrc = node.attrs.src; - if (remoteImageSrc) { + if (resolvedSrc) { setIsUploaded(true); setImageFromFileSystem(undefined); } else { setIsUploaded(false); } - }, [node.attrs.src]); + }, [resolvedSrc]); + + useEffect(() => { + const getImageSource = async () => { + // @ts-expect-error function not expected here, but will still work and don't remove await + const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc); + setResolvedSrc(url as string); + }; + getImageSource(); + }, [imageFromFileSystem, node.attrs.src]); return ( @@ -54,6 +65,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { imageFromFileSystem={imageFromFileSystem} editorContainer={editorContainer} editor={editor} + src={resolvedSrc} getPos={getPos} node={node} setEditorContainer={setEditorContainer} @@ -67,6 +79,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { failedToLoadImage={failedToLoadImage} getPos={getPos} loadImageFromFileSystem={setImageFromFileSystem} + maxFileSize={editor.storage.imageComponent.maxFileSize} node={node} setIsUploaded={setIsUploaded} selected={selected} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index b5c52db66..8ad99bc44 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -1,42 +1,36 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor } from "@tiptap/core"; import { ImageIcon } from "lucide-react"; // helpers import { cn } from "@/helpers/common"; // hooks import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; // extensions -import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; +import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; -export const CustomImageUploader = (props: { - failedToLoadImage: boolean; - editor: Editor; - selected: boolean; +type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { + maxFileSize: number; loadImageFromFileSystem: (file: string) => void; + failedToLoadImage: boolean; setIsUploaded: (isUploaded: boolean) => void; - node: ProsemirrorNode & { - attrs: ImageAttributes; - }; - updateAttributes: (attrs: Record) => void; - getPos: () => number; -}) => { +}; + +export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { - selected, - failedToLoadImage, editor, + failedToLoadImage, + getPos, loadImageFromFileSystem, + maxFileSize, node, + selected, setIsUploaded, updateAttributes, - getPos, } = props; - // ref + // refs const fileInputRef = useRef(null); - const hasTriggeredFilePickerRef = useRef(false); - const imageEntityId = node.attrs.id; - + const { id: imageEntityId } = node.attrs; + // derived values const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); const onUpload = useCallback( @@ -71,11 +65,17 @@ export const CustomImageUploader = (props: { [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); // hooks - const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); - const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ - uploader: uploadFile, + const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ editor, + loadImageFromFileSystem, + maxFileSize, + onUpload, + }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + editor, + maxFileSize, pos: getPos(), + uploader: uploadFile, }); // the meta data of the image component @@ -102,11 +102,17 @@ export const CustomImageUploader = (props: { const onFileChange = useCallback( async (e: ChangeEvent) => { e.preventDefault(); - const fileList = e.target.files; - if (!fileList) { + const filesList = e.target.files; + if (!filesList) { return; } - await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile); + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos: getPos(), + uploader: uploadFile, + }); }, [uploadFile, editor, getPos] ); diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 939d97668..a232bb258 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -1,9 +1,11 @@ import { Editor, mergeAttributes } from "@tiptap/core"; import { Image } from "@tiptap/extension-image"; +import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node } from "@tiptap/pm/model"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { v4 as uuidv4 } from "uuid"; // extensions -import { CustomImageNode } from "@/extensions/custom-image"; +import { CustomImageNode, ImageAttributes } from "@/extensions/custom-image"; // plugins import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; // types @@ -22,6 +24,8 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (file: File) => () => Promise | undefined; + getImageSource?: (path: string) => () => Promise; + restoreImage: (src: string) => () => Promise; }; } } @@ -36,7 +40,13 @@ export interface UploadImageExtensionStorage { export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; export const CustomImageExtension = (props: TFileHandler) => { - const { upload, delete: deleteImage, restore: restoreImage } = props; + const { + getAssetSrc, + upload, + delete: deleteImageFn, + restore: restoreImageFn, + validation: { maxFileSize }, + } = props; return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", @@ -78,23 +88,6 @@ export const CustomImageExtension = (props: TFileHandler) => { return ["image-component", mergeAttributes(HTMLAttributes)]; }, - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -104,16 +97,44 @@ export const CustomImageExtension = (props: TFileHandler) => { addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + await restoreImageFn(src); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + addStorage() { return { fileMap: new Map(), deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, + markdown: { + serialize(state: MarkdownSerializerState, node: Node) { + const attrs = node.attrs as ImageAttributes; + const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src); + const imageWidth = state.esc(attrs.width?.toString()); + state.write(``); + state.closeBlock(node); + }, + }, }; }, @@ -123,7 +144,13 @@ export const CustomImageExtension = (props: TFileHandler) => { (props: { file?: File; pos?: number; event: "insert" | "drop" }) => ({ commands }) => { // Early return if there's an invalid file being dropped - if (props?.file && !isFileValid(props.file)) { + if ( + props?.file && + !isFileValid({ + file: props.file, + maxFileSize, + }) + ) { return false; } @@ -166,6 +193,10 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(file); return fileUrl; }, + getImageSource: (path: string) => async () => await getAssetSrc(path), + restoreImage: (src: string) => async () => { + await restoreImageFn(src); + }, }; }, diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index f7db8d6b0..3248329f0 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -1,11 +1,17 @@ import { mergeAttributes } from "@tiptap/core"; import { Image } from "@tiptap/extension-image"; +import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node } from "@tiptap/pm/model"; import { ReactNodeViewRenderer } from "@tiptap/react"; // components -import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; +import { CustomImageNode, ImageAttributes, UploadImageExtensionStorage } from "@/extensions/custom-image"; +// types +import { TFileHandler } from "@/types"; -export const CustomReadOnlyImageExtension = () => - Image.extend, UploadImageExtensionStorage>({ +export const CustomReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", selectable: false, group: "block", @@ -48,6 +54,21 @@ export const CustomReadOnlyImageExtension = () => addStorage() { return { fileMap: new Map(), + markdown: { + serialize(state: MarkdownSerializerState, node: Node) { + const attrs = node.attrs as ImageAttributes; + const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src); + const imageWidth = state.esc(attrs.width?.toString()); + state.write(``); + state.closeBlock(node); + }, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => async () => await getAssetSrc(path), }; }, @@ -55,3 +76,4 @@ export const CustomReadOnlyImageExtension = () => return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index c6d29b31b..959d20e2b 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -8,15 +8,18 @@ import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; // extensions import { + CustomCalloutExtension, CustomCodeBlockExtension, CustomCodeInlineExtension, CustomCodeMarkPlugin, + CustomColorExtension, CustomHorizontalRule, CustomImageExtension, CustomKeymap, CustomLinkExtension, CustomMention, CustomQuoteExtension, + CustomTextAlignExtension, CustomTypographyExtension, DropHandlerExtension, ImageExtension, @@ -29,16 +32,11 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; +import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types"; type TArguments = { enableHistory: boolean; - fileConfig: { - deleteFile: DeleteImage; - restoreFile: RestoreImage; - cancelUploadImage?: () => void; - uploadFile: UploadImage; - }; + fileHandler: TFileHandler; mentionConfig: { mentionSuggestions?: () => Promise; mentionHighlights?: () => Promise; @@ -47,123 +45,122 @@ type TArguments = { tabIndex?: number; }; -export const CoreEditorExtensions = ({ - enableHistory, - fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile }, - mentionConfig, - placeholder, - tabIndex, -}: TArguments) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +export const CoreEditorExtensions = (args: TArguments) => { + const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: { - class: "text-custom-text-300", - }, - ...(enableHistory ? {} : { history: false }), - }), - CustomQuoteExtension, - DropHandlerExtension(), - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomKeymap, - ListKeymap({ tabIndex }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomImageExtension({ - delete: deleteFile, - restore: restoreFile, - upload: uploadFile, - cancel: cancelUploadImage ?? (() => {}), - }), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeMarkPlugin, - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformPastedText: true, - breaks: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionSuggestions: mentionConfig.mentionSuggestions, - mentionHighlights: mentionConfig.mentionHighlights, - readonly: false, - }), - Placeholder.configure({ - placeholder: ({ editor, node }) => { - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + class: "text-custom-text-300", + }, + ...(enableHistory ? {} : { history: false }), + }), + CustomQuoteExtension, + DropHandlerExtension(), + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "py-4 border-custom-border-400", + }, + }), + CustomKeymap, + ListKeymap({ tabIndex }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtension(fileHandler).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomImageExtension(fileHandler), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformPastedText: true, + breaks: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: false, + }), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - if (editor.storage.imageComponent.uploadInProgress) return ""; + if (editor.storage.imageComponent.uploadInProgress) return ""; - const shouldHidePlaceholder = - editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + const shouldHidePlaceholder = + editor.isActive("table") || + editor.isActive("codeBlock") || + editor.isActive("image") || + editor.isActive("imageComponent"); - if (shouldHidePlaceholder) return ""; + if (shouldHidePlaceholder) return ""; - if (placeholder) { - if (typeof placeholder === "string") return placeholder; - else return placeholder(editor.isFocused, editor.getHTML()); - } + if (placeholder) { + if (typeof placeholder === "string") return placeholder; + else return placeholder(editor.isFocused, editor.getHTML()); + } - return "Press '/' for commands..."; - }, - includeChildren: true, - }), - CharacterCount, -]; + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + CharacterCount, + CustomTextAlignExtension, + CustomCalloutExtension, + CustomColorExtension, + ]; +}; diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 1f15846a1..f549719f2 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -5,22 +5,30 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par // plugins import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types -import { DeleteImage, RestoreImage } from "@/types"; +import { TFileHandler } from "@/types"; // extensions import { CustomImageNode } from "@/extensions"; -export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => - ImageExt.extend({ +export const ImageExtension = (fileHandler: TFileHandler) => { + const { + getAssetSrc, + delete: deleteImageFn, + restore: restoreImageFn, + validation: { maxFileSize }, + } = fileHandler; + + return ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, + addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, @@ -28,13 +36,14 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm const imageSources = new Set(); this.editor.state.doc.descendants((node) => { if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + imageSources.add(node.attrs.src); } }); imageSources.forEach(async (src) => { try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImageFn(src); } catch (error) { console.error("Error restoring image: ", error); } @@ -46,6 +55,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm return { deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, }; }, @@ -58,6 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm height: { default: null, }, + aspectRatio: { + default: null, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => async () => await getAssetSrc(path), }; }, @@ -66,3 +85,4 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index 0d505000c..bb6c5b4ad 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -11,6 +11,9 @@ export const ImageExtensionWithoutProps = () => height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, }); diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 1605174b3..ce1581a8e 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -2,20 +2,36 @@ import Image from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; +// types +import { TFileHandler } from "@/types"; -export const ReadOnlyImageExtension = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, -}); +export const ReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => async () => await getAssetSrc(path), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 9209f9480..d1fa0ce6d 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -1,3 +1,4 @@ +export * from "./callout"; export * from "./code"; export * from "./code-inline"; export * from "./custom-image"; @@ -6,17 +7,19 @@ export * from "./custom-list-keymap"; export * from "./image"; export * from "./issue-embed"; export * from "./mentions"; +export * from "./slash-commands"; export * from "./table"; export * from "./typography"; export * from "./core-without-props"; export * from "./custom-code-inline"; +export * from "./custom-color"; export * from "./drop"; export * from "./enter-key-extension"; export * from "./extensions"; +export * from "./headers"; export * from "./horizontal-rule"; export * from "./keymap"; export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; -export * from "./slash-commands"; -export * from "./headers"; +export * from "./text-align"; diff --git a/packages/editor/src/core/extensions/mentions/extension.tsx b/packages/editor/src/core/extensions/mentions/extension.tsx index 5653a3540..32ad8a720 100644 --- a/packages/editor/src/core/extensions/mentions/extension.tsx +++ b/packages/editor/src/core/extensions/mentions/extension.tsx @@ -1,5 +1,7 @@ import { Editor, mergeAttributes } from "@tiptap/core"; import Mention, { MentionOptions } from "@tiptap/extension-mention"; +import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node } from "@tiptap/pm/model"; import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react"; import tippy from "tippy.js"; // extensions @@ -25,8 +27,19 @@ export const CustomMention = ({ addStorage(this) { return { mentionsOpen: false, + markdown: { + serialize(state: MarkdownSerializerState, node: Node) { + const { attrs } = node; + const label = `@${state.esc(attrs.label)}`; + const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const safeRedirectionPath = state.esc(attrs.redirect_uri); + const url = `${originUrl}${safeRedirectionPath}`; + state.write(`[${label}](${url})`); + }, + }, }; }, + addAttributes() { return { id: { diff --git a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx index ca2f39b8c..5121eae4a 100644 --- a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -18,16 +18,22 @@ export const MentionNodeView = (props) => { useEffect(() => { if (!props.extension.options.mentionHighlights) return; const hightlights = async () => { - const userId = await props.extension.options.mentionHighlights(); + const userId = await props.extension.options.mentionHighlights?.(); setHighlightsState(userId); }; hightlights(); }, [props.extension.options]); + const handleClick = (event: React.MouseEvent) => { + if (!props.node.attrs.redirect_uri) { + event.preventDefault(); + } + }; + return ( Promise; -}) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +type Props = { + fileHandler: Pick; + mentionConfig: { + mentionHighlights?: () => Promise; + }; +}; + +export const CoreReadOnlyEditorExtensions = (props: Props) => { + const { fileHandler, mentionConfig } = props; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: false, - gapcursor: false, - }), - CustomQuoteExtension, - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ReadOnlyImageExtension.configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomReadOnlyImageExtension(), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative pointer-events-none", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformCopiedText: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), - CharacterCount, - HeadingListExtension, -]; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: false, + gapcursor: false, + }), + CustomQuoteExtension, + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "py-4 border-custom-border-400", + }, + }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative pointer-events-none", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionHighlights: mentionConfig.mentionHighlights, + readonly: true, + }), + CharacterCount, + CustomColorExtension, + HeadingListExtension, + CustomTextAlignExtension, + CustomCalloutReadOnlyExtension, + ]; +}; diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx deleted file mode 100644 index 2be8d89d9..000000000 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; -import { Editor, Range, Extension } from "@tiptap/core"; -import { ReactRenderer } from "@tiptap/react"; -import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; -import { - CaseSensitive, - Code2, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - ImageIcon, - List, - ListOrdered, - ListTodo, - MinusSquare, - Quote, - Table, -} from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; -import { - insertTableCommand, - toggleBlockquote, - toggleBulletList, - toggleOrderedList, - toggleTaskList, - toggleHeadingOne, - toggleHeadingTwo, - toggleHeadingThree, - toggleHeadingFour, - toggleHeadingFive, - toggleHeadingSix, - insertImage, -} from "@/helpers/editor-commands"; -// types -import { CommandProps, ISlashCommandItem } from "@/types"; - -interface CommandItemProps { - key: string; - title: string; - description: string; - icon: ReactNode; -} - -export type SlashCommandOptions = { - suggestion: Omit; -}; - -const Command = Extension.create({ - name: "slash-command", - addOptions() { - return { - suggestion: { - char: "/", - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { - props.command({ editor, range }); - }, - allow({ editor }: { editor: Editor }) { - const { selection } = editor.state; - - const parentNode = selection.$from.node(selection.$from.depth); - const blockType = parentNode.type.name; - - if (blockType === "codeBlock") { - return false; - } - - if (editor.isActive("table")) { - return false; - } - - return true; - }, - }, - }; - }, - addProseMirrorPlugins() { - return [ - Suggestion({ - editor: this.editor, - ...this.options.suggestion, - }), - ]; - }, -}); - -const getSuggestionItems = - (additionalOptions?: Array) => - ({ query }: { query: string }) => { - let slashCommands: ISlashCommandItem[] = [ - { - key: "text", - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }: CommandProps) => { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().run(); - } - editor.chain().focus().clearNodes().run(); - }, - }, - { - key: "h1", - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); - }, - }, - { - key: "h2", - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); - }, - }, - { - key: "h3", - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); - }, - }, - { - key: "h4", - title: "Heading 4", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFour(editor, range); - }, - }, - { - key: "h5", - title: "Heading 5", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFive(editor, range); - }, - }, - { - key: "h6", - title: "Heading 6", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingSix(editor, range); - }, - }, - { - key: "to-do-list", - title: "To do", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range); - }, - }, - { - key: "bulleted-list", - title: "Bullet list", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); - }, - }, - { - key: "numbered-list", - title: "Numbered list", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); - }, - }, - { - key: "table", - title: "Table", - description: "Create a table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); - }, - }, - { - key: "quote", - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range), - }, - { - key: "code", - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - key: "image", - title: "Image", - icon: , - description: "Insert an image", - searchTerms: ["img", "photo", "picture", "media", "upload"], - command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), - }, - { - key: "divider", - title: "Divider", - description: "Visually divide blocks.", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); - }, - }, - ]; - - if (additionalOptions) { - additionalOptions.map((item) => { - slashCommands.push(item); - }); - } - - slashCommands = slashCommands.filter((item) => { - if (typeof query === "string" && query.length > 0) { - const search = query.toLowerCase(); - return ( - item.title.toLowerCase().includes(search) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) - ); - } - return true; - }); - - return slashCommands; - }; - -export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - container.scrollTop -= container.scrollTop - top + 5; - } else if (bottom > containerHeight + container.scrollTop) { - container.scrollTop += bottom - containerHeight - container.scrollTop + 5; - } -}; - -const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => { - // states - const [selectedIndex, setSelectedIndex] = useState(0); - // refs - const commandListContainer = useRef(null); - - const selectItem = useCallback( - (index: number) => { - const item = items[index]; - if (item) command(item); - }, - [command, items] - ); - - useEffect(() => { - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - const onKeyDown = (e: KeyboardEvent) => { - if (navigationKeys.includes(e.key)) { - e.preventDefault(); - if (e.key === "ArrowUp") { - setSelectedIndex((selectedIndex + items.length - 1) % items.length); - return true; - } - if (e.key === "ArrowDown") { - setSelectedIndex((selectedIndex + 1) % items.length); - return true; - } - if (e.key === "Enter") { - selectItem(selectedIndex); - return true; - } - return false; - } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [items, selectedIndex, setSelectedIndex, selectItem]); - - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - - const item = container?.children[selectedIndex] as HTMLElement; - - if (item && container) updateScrollView(container, item); - }, [selectedIndex]); - - if (items.length <= 0) return null; - - return ( -
- {items.map((item, index) => ( - - ))} -
- ); -}; - -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - -const renderItems = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component = new ReactRenderer(CommandList, { - props, - editor: props.editor, - }); - - const tippyContainer = - document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); - - // @ts-expect-error Tippy overloads are messed up - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: tippyContainer, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - if (component?.ref?.onKeyDown(props)) { - return true; - } - return false; - }, - onExit: () => { - popup?.[0].destroy(); - component?.destroy(); - }, - }; -}; - -export const SlashCommand = (additionalOptions?: Array) => - Command.configure({ - suggestion: { - items: getSuggestionItems(additionalOptions), - render: renderItems, - }, - }); diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx new file mode 100644 index 000000000..c19bda306 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -0,0 +1,301 @@ +import { + ALargeSmall, + CaseSensitive, + Code2, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + ImageIcon, + List, + ListOrdered, + ListTodo, + MessageSquareText, + MinusSquare, + Table, + TextQuote, +} from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { + insertTableCommand, + toggleBlockquote, + toggleBulletList, + toggleOrderedList, + toggleTaskList, + toggleHeadingOne, + toggleHeadingTwo, + toggleHeadingThree, + toggleHeadingFour, + toggleHeadingFive, + toggleHeadingSix, + toggleTextColor, + toggleBackgroundColor, + insertImage, + insertCallout, + setText, +} from "@/helpers/editor-commands"; +// types +import { CommandProps, ISlashCommandItem } from "@/types"; + +export type TSlashCommandSection = { + key: string; + title?: string; + items: ISlashCommandItem[]; +}; + +export const getSlashCommandFilteredSections = + (additionalOptions?: ISlashCommandItem[]) => + ({ query }: { query: string }): TSlashCommandSection[] => { + const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ + { + key: "general", + items: [ + { + commandKey: "text", + key: "text", + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }) => setText(editor, range), + }, + { + commandKey: "h1", + key: "h1", + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }) => toggleHeadingOne(editor, range), + }, + { + commandKey: "h2", + key: "h2", + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }) => toggleHeadingTwo(editor, range), + }, + { + commandKey: "h3", + key: "h3", + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingThree(editor, range), + }, + { + commandKey: "h4", + key: "h4", + title: "Heading 4", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFour(editor, range), + }, + { + commandKey: "h5", + key: "h5", + title: "Heading 5", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFive(editor, range), + }, + { + commandKey: "h6", + key: "h6", + title: "Heading 6", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingSix(editor, range), + }, + { + commandKey: "to-do-list", + key: "to-do-list", + title: "To do", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }) => toggleTaskList(editor, range), + }, + { + commandKey: "bulleted-list", + key: "bulleted-list", + title: "Bullet list", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }) => toggleBulletList(editor, range), + }, + { + commandKey: "numbered-list", + key: "numbered-list", + title: "Numbered list", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }) => toggleOrderedList(editor, range), + }, + { + commandKey: "table", + key: "table", + title: "Table", + description: "Create a table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }) => insertTableCommand(editor, range), + }, + { + commandKey: "quote", + key: "quote", + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }) => toggleBlockquote(editor, range), + }, + { + commandKey: "code", + key: "code", + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + commandKey: "image", + key: "image", + title: "Image", + icon: , + description: "Insert an image", + searchTerms: ["img", "photo", "picture", "media", "upload"], + command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), + }, + { + commandKey: "callout", + key: "callout", + title: "Callout", + icon: , + description: "Insert callout", + searchTerms: ["callout", "comment", "message", "info", "alert"], + command: ({ editor, range }: CommandProps) => insertCallout(editor, range), + }, + { + commandKey: "divider", + key: "divider", + title: "Divider", + description: "Visually divide blocks.", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, + ], + }, + { + key: "text-color", + title: "Colors", + items: [ + { + commandKey: "text-color", + key: "text-color-default", + title: "Default", + description: "Change text color", + searchTerms: ["color", "text", "default"], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "text-color", + key: `text-color-${color.key}`, + title: color.label, + description: "Change text color", + searchTerms: ["color", "text", color.label], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(color.key, editor, range), + }) as ISlashCommandItem + ), + ], + }, + { + key: "background-color", + title: "Background colors", + items: [ + { + commandKey: "background-color", + key: "background-color-default", + title: "Default background", + description: "Change background color", + searchTerms: ["color", "bg", "background", "default"], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: "rgba(var(--color-background-100))", + border: "1px solid rgba(var(--color-border-300))", + }, + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "background-color", + key: `background-color-${color.key}`, + title: color.label, + description: "Change background color", + searchTerms: ["color", "bg", "background", color.label], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: color.backgroundColor, + }, + command: ({ editor, range }) => toggleBackgroundColor(color.key, editor, range), + }) as ISlashCommandItem + ), + ], + }, + ]; + + additionalOptions?.map((item) => { + SLASH_COMMAND_SECTIONS?.[0]?.items.push(item); + }); + + const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({ + ...section, + items: section.items.filter((item) => { + if (typeof query !== "string") return; + + const lowercaseQuery = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(lowercaseQuery) || + item.description.toLowerCase().includes(lowercaseQuery) || + item.searchTerms.some((t) => t.includes(lowercaseQuery)) + ); + }), + })); + + return filteredSlashSections.filter((s) => s.items.length !== 0); + }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx new file mode 100644 index 000000000..3a03c3b6a --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -0,0 +1,37 @@ +// helpers +import { cn } from "@/helpers/common"; +// types +import { ISlashCommandItem } from "@/types"; + +type Props = { + isSelected: boolean; + item: ISlashCommandItem; + itemIndex: number; + onClick: (e: React.MouseEvent) => void; + onMouseEnter: () => void; + sectionIndex: number; +}; + +export const CommandMenuItem: React.FC = (props) => { + const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; + + return ( + + ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx new file mode 100644 index 000000000..d6148b69a --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +// components +import { TSlashCommandSection } from "./command-items-list"; +import { CommandMenuItem } from "./command-menu-item"; + +export type SlashCommandsMenuProps = { + items: TSlashCommandSection[]; + command: any; +}; + +export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { + const { items: sections, command } = props; + // states + const [selectedIndex, setSelectedIndex] = useState({ + section: 0, + item: 0, + }); + // refs + const commandListContainer = useRef(null); + + const selectItem = useCallback( + (sectionIndex: number, itemIndex: number) => { + const item = sections[sectionIndex]?.items?.[itemIndex]; + if (item) command(item); + }, + [command, sections] + ); + // handle arrow key navigation + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + const currentSection = selectedIndex.section; + const currentItem = selectedIndex.item; + let nextSection = currentSection; + let nextItem = currentItem; + + if (e.key === "ArrowUp") { + nextItem = currentItem - 1; + if (nextItem < 0) { + nextSection = currentSection - 1; + if (nextSection < 0) nextSection = sections.length - 1; + nextItem = sections[nextSection].items.length - 1; + } + } + if (e.key === "ArrowDown") { + nextItem = currentItem + 1; + if (nextItem >= sections[currentSection].items.length) { + nextSection = currentSection + 1; + if (nextSection >= sections.length) nextSection = 0; + nextItem = 0; + } + } + if (e.key === "Enter") { + selectItem(currentSection, currentItem); + } + setSelectedIndex({ + section: nextSection, + item: nextItem, + }); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [sections, selectedIndex, setSelectedIndex, selectItem]); + // initialize the select index to 0 by default + useEffect(() => { + setSelectedIndex({ + section: 0, + item: 0, + }); + }, [sections]); + // scroll to the dropdown item when navigating via keyboard + useLayoutEffect(() => { + const container = commandListContainer?.current; + if (!container) return; + + const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; + + // use scroll into view to bring the item in view if it is not in view + item?.scrollIntoView({ block: "nearest" }); + }, [sections, selectedIndex]); + + const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0; + + if (areSearchResultsEmpty) return null; + + return ( +
+ {sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} +
+ {section.items.map((item, itemIndex) => ( + { + e.stopPropagation(); + selectItem(sectionIndex, itemIndex); + }} + onMouseEnter={() => + setSelectedIndex({ + section: sectionIndex, + item: itemIndex, + }) + } + sectionIndex={sectionIndex} + /> + ))} +
+
+ ))} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx new file mode 100644 index 000000000..a99cbc5f9 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -0,0 +1,111 @@ +import { Editor, Range, Extension } from "@tiptap/core"; +import { ReactRenderer } from "@tiptap/react"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; +import tippy from "tippy.js"; +// types +import { ISlashCommandItem } from "@/types"; +// components +import { getSlashCommandFilteredSections } from "./command-items-list"; +import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu"; + +export type SlashCommandOptions = { + suggestion: Omit; +}; + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + props.command({ editor, range }); + }, + allow({ editor }: { editor: Editor }) { + const { selection } = editor.state; + + const parentNode = selection.$from.node(selection.$from.depth); + const blockType = parentNode.type.name; + + if (blockType === "codeBlock") { + return false; + } + + if (editor.isActive("table")) { + return false; + } + + return true; + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); + +interface CommandListInstance { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +} + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + return { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component = new ReactRenderer(SlashCommandsMenu, { + props, + editor: props.editor, + }); + + const tippyContainer = + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: tippyContainer, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component?.updateProps(props); + + popup?.[0]?.setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + if (component?.ref?.onKeyDown(props)) { + return true; + } + return false; + }, + onExit: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) => + Command.configure({ + suggestion: { + items: getSlashCommandFilteredSections(additionalOptions), + render: renderItems, + }, + }); diff --git a/packages/editor/src/core/extensions/text-align.ts b/packages/editor/src/core/extensions/text-align.ts new file mode 100644 index 000000000..bfe62f6c0 --- /dev/null +++ b/packages/editor/src/core/extensions/text-align.ts @@ -0,0 +1,8 @@ +import TextAlign from "@tiptap/extension-text-align"; + +export type TTextAlign = "left" | "center" | "right"; + +export const CustomTextAlignExtension = TextAlign.configure({ + alignments: ["left", "center", "right"], + types: ["heading", "paragraph"], +}); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 66be05bb2..ec593d536 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -7,8 +7,8 @@ import { findTableAncestor } from "@/helpers/common"; import { InsertImageComponentProps } from "@/extensions"; export const setText = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().run(); - else editor.chain().focus().clearNodes().run(); + if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run(); + else editor.chain().focus().setNode("paragraph").run(); }; export const toggleHeadingOne = (editor: Editor, range?: Range) => { @@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => { export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; + +export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) editor.chain().focus().deleteRange(range).setTextColor(color).run(); + else editor.chain().focus().setTextColor(color).run(); + } else { + if (range) editor.chain().focus().deleteRange(range).unsetTextColor().run(); + else editor.chain().focus().unsetTextColor().run(); + } +}; + +export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) { + editor.chain().focus().deleteRange(range).setBackgroundColor(color).run(); + } else { + editor.chain().focus().setBackgroundColor(color).run(); + } + } else { + if (range) { + editor.chain().focus().deleteRange(range).unsetBackgroundColor().run(); + } else { + editor.chain().focus().unsetBackgroundColor().run(); + } + } +}; + +export const setTextAlign = (alignment: string, editor: Editor) => { + editor.chain().focus().setTextAlign(alignment).run(); +}; + +export const insertHorizontalRule = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + else editor.chain().focus().setHorizontalRule().run(); +}; +export const insertCallout = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).insertCallout().run(); + else editor.chain().focus().insertCallout().run(); +}; diff --git a/packages/editor/src/core/helpers/scroll-to-node.ts b/packages/editor/src/core/helpers/scroll-to-node.ts index 65d32a7d2..973f3cf14 100644 --- a/packages/editor/src/core/helpers/scroll-to-node.ts +++ b/packages/editor/src/core/helpers/scroll-to-node.ts @@ -32,6 +32,26 @@ function scrollToNode(editor: Editor, pos: number): void { } } +export function scrollToNodeViaDOMCoordinates(editor: Editor, pos: number, behavior?: ScrollBehavior): void { + const view = editor.view; + + // Get the coordinates of the position + const coords = view.coordsAtPos(pos); + + if (coords) { + // Scroll to the coordinates + window.scrollTo({ + top: coords.top + window.scrollY - window.innerHeight / 2, + behavior: behavior, + }); + + // Optionally, you can also focus the editor + view.focus(); + } else { + console.warn("Unable to find coordinates for the given position"); + } +} + export function scrollSummary(editor: Editor, marking: IMarking) { if (editor) { const pos = findNthH1(editor, marking.sequence, marking.level); diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 5a004bff2..5bee8c0c3 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -13,6 +13,7 @@ import { TCollaborativeEditorProps } from "@/types"; export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const { + onTransaction, disabledExtensions, editorClassName, editorProps = {}, @@ -39,7 +40,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { name: id, parameters: realtimeConfig.queryParams, // using user id as a token to verify the user on the server - token: user.id, + token: JSON.stringify(user), url: realtimeConfig.url, onAuthenticationFailed: () => { serverHandler?.onServerError?.(); @@ -54,7 +55,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { }, onSynced: () => setHasServerSynced(true), }), - [id, realtimeConfig, serverHandler, user.id] + [id, realtimeConfig, serverHandler, user] ); // destroy and disconnect connection on unmount @@ -75,6 +76,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const editor = useEditor({ id, + onTransaction, editorProps, editorClassName, enableHistory: false, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 65e36c01a..eef72797c 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -6,13 +6,13 @@ import { EditorProps } from "@tiptap/pm/view"; import { useEditor as useTiptapEditor, Editor } from "@tiptap/react"; import * as Y from "yjs"; // components -import { getEditorMenuItems } from "@/components/menus"; +import { EditorMenuItem, getEditorMenuItems } from "@/components/menus"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers import { getParagraphCount } from "@/helpers/common"; import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position"; -import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; +import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node"; // props import { CoreEditorProps } from "@/props"; // types @@ -33,6 +33,8 @@ export interface CustomEditorProps { suggestions?: () => Promise; }; onChange?: (json: object, html: string) => void; + onTransaction?: () => void; + autofocus?: boolean; placeholder?: string | ((isFocused: boolean, value: string) => string); provider?: HocuspocusProvider; tabIndex?: number; @@ -54,10 +56,12 @@ export const useEditor = (props: CustomEditorProps) => { initialValue, mentionHandler, onChange, + onTransaction, placeholder, provider, tabIndex, value, + autofocus = false, } = props; // states @@ -66,6 +70,7 @@ export const useEditor = (props: CustomEditorProps) => { const editorRef: MutableRefObject = useRef(null); const savedSelectionRef = useRef(savedSelection); const editor = useTiptapEditor({ + autofocus, editorProps: { ...CoreEditorProps({ editorClassName, @@ -75,12 +80,7 @@ export const useEditor = (props: CustomEditorProps) => { extensions: [ ...CoreEditorExtensions({ enableHistory, - fileConfig: { - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - restoreFile: fileHandler.restore, - cancelUploadImage: fileHandler.cancel, - }, + fileHandler, mentionConfig: { mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), mentionHighlights: mentionHandler.highlights, @@ -92,7 +92,10 @@ export const useEditor = (props: CustomEditorProps) => { ], content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", onCreate: () => handleEditorReady?.(true), - onTransaction: ({ editor }) => setSavedSelection(editor.state.selection), + onTransaction: ({ editor }) => { + setSavedSelection(editor.state.selection); + onTransaction?.(); + }, onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), onDestroy: () => handleEditorReady?.(false), }); @@ -125,6 +128,13 @@ export const useEditor = (props: CustomEditorProps) => { useImperativeHandle( forwardedRef, () => ({ + blur: () => editorRef.current?.commands.blur(), + scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) { + const resolvedPos = pos ?? savedSelection?.from; + if (!editorRef.current || !resolvedPos) return; + scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior); + }, + getCurrentCursorPosition: () => savedSelection?.from, clearEditor: (emitUpdate = false) => { editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, @@ -136,7 +146,8 @@ export const useEditor = (props: CustomEditorProps) => { insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeMenuItemCommand: (itemKey: TEditorCommands) => { + executeMenuItemCommand: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); @@ -144,20 +155,25 @@ export const useEditor = (props: CustomEditorProps) => { const item = getEditorMenuItem(itemKey); if (item) { if (item.key === "image") { - item.command(savedSelectionRef.current); + (item as EditorMenuItem<"image">).command({ + savedSelection: savedSelectionRef.current, + }); } else { - item.command(); + item.command(props); } } else { console.warn(`No command found for item: ${itemKey}`); } }, - isMenuItemActive: (itemName: TEditorCommands): boolean => { + isMenuItemActive: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); - const item = getEditorMenuItem(itemName); - return item ? item.isActive() : false; + const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); + const item = getEditorMenuItem(itemKey); + if (!item) return false; + + return item.isActive(props); }, onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index f1bc8c8a1..f5f930f29 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,17 +1,20 @@ import { DragEvent, useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/core"; -import { isFileValid } from "@/plugins/image"; +// extensions import { insertImagesSafely } from "@/extensions/drop"; +// plugins +import { isFileValid } from "@/plugins/image"; -export const useUploader = ({ - onUpload, - editor, - loadImageFromFileSystem, -}: { - onUpload: (url: string) => void; +type TUploaderArgs = { editor: Editor; loadImageFromFileSystem: (file: string) => void; -}) => { + maxFileSize: number; + onUpload: (url: string) => void; +}; + +export const useUploader = (args: TUploaderArgs) => { + const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + // states const [uploading, setUploading] = useState(false); const uploadFile = useCallback( @@ -23,7 +26,10 @@ export const useUploader = ({ setUploading(true); const fileNameTrimmed = trimFileName(file.name); const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); - const isValid = isFileValid(fileWithTrimmedName); + const isValid = isFileValid({ + file: fileWithTrimmedName, + maxFileSize, + }); if (!isValid) { setImageUploadInProgress(false); return; @@ -64,15 +70,16 @@ export const useUploader = ({ return { uploading, uploadFile }; }; -export const useDropZone = ({ - uploader, - editor, - pos, -}: { - uploader: (file: File) => Promise; +type TDropzoneArgs = { editor: Editor; + maxFileSize: number; pos: number; -}) => { + uploader: (file: File) => Promise; +}; + +export const useDropZone = (args: TDropzoneArgs) => { + const { editor, maxFileSize, pos, uploader } = args; + // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -101,8 +108,14 @@ export const useDropZone = ({ if (e.dataTransfer.files.length === 0) { return; } - const fileList = e.dataTransfer.files; - await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader); + const filesList = e.dataTransfer.files; + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos, + uploader, + }); }, [uploader, editor, pos] ); @@ -129,22 +142,33 @@ function trimFileName(fileName: string, maxLength = 100) { return fileName; } +type TMultipleImagesArgs = { + editor: Editor; + filesList: FileList; + maxFileSize: number; + pos: number; + uploader: (file: File) => Promise; +}; + // Upload the first image and insert the remaining images for uploading multiple image // post insertion of image-component -export async function uploadFirstImageAndInsertRemaining( - editor: Editor, - fileList: FileList, - pos: number, - uploaderFn: (file: File) => Promise -) { +export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) { + const { editor, filesList, maxFileSize, pos, uploader } = args; const filteredFiles: File[] = []; - for (let i = 0; i < fileList.length; i += 1) { - const item = fileList.item(i); - if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) { + for (let i = 0; i < filesList.length; i += 1) { + const item = filesList.item(i); + if ( + item && + item.type.indexOf("image") !== -1 && + isFileValid({ + file: item, + maxFileSize, + }) + ) { filteredFiles.push(item); } } - if (filteredFiles.length !== fileList.length) { + if (filteredFiles.length !== filesList.length) { console.warn("Some files were not images and have been ignored."); } if (filteredFiles.length === 0) { @@ -154,7 +178,7 @@ export async function uploadFirstImageAndInsertRemaining( // Upload the first image const firstFile = filteredFiles[0]; - uploaderFn(firstFile); + uploader(firstFile); // Insert the remaining images const remainingFiles = filteredFiles.slice(1); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 1aff29aa7..d40819229 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit editorClassName, editorProps = {}, extensions, + fileHandler, forwardedRef, handleEditorReady, id, @@ -31,7 +32,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit new HocuspocusProvider({ url: realtimeConfig.url, name: id, - token: user.id, + token: JSON.stringify(user), parameters: realtimeConfig.queryParams, onAuthenticationFailed: () => { serverHandler?.onServerError?.(); @@ -46,7 +47,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit }, onSynced: () => setHasServerSynced(true), }), - [id, realtimeConfig, user.id] + [id, realtimeConfig, user] ); // destroy and disconnect connection on unmount useEffect( @@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit document: provider.document, }), ], + fileHandler, forwardedRef, handleEditorReady, mentionHandler, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 6d1ed6fa9..23ce023ad 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types"; interface CustomReadOnlyEditorProps { initialValue?: string; @@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps { forwardedRef?: MutableRefObject; extensions?: any; editorProps?: EditorProps; + fileHandler: Pick; handleEditorReady?: (value: boolean) => void; mentionHandler: { highlights: () => Promise; @@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { forwardedRef, extensions = [], editorProps = {}, + fileHandler, handleEditorReady, mentionHandler, provider, @@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionHighlights: mentionHandler.highlights, + mentionConfig: { + mentionHighlights: mentionHandler.highlights, + }, + fileHandler, }), ...extensions, ], diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 809802b4f..1c015dcb0 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -43,6 +43,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { ".issue-embed", ".image-component", ".image-upload-component", + ".editor-callout-component", ].join(", "); for (const elem of elements) { diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 21c8cd24f..bcede7707 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -47,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag }); async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await deleteImage(assetUrlWithWorkspaceId); + await deleteImage(src); } catch (error) { console.error("Error deleting image: ", error); } diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index d722e53a6..4eecf01d7 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; + // if the src is just a id (private bucket), then we don't need to handle restore from here but + // only while it fails to load + if (!node.attrs.src?.startsWith("http")) return; addedImages.push(node as ImageNode); }); @@ -48,10 +51,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor }); async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImage(src); } catch (error) { console.error("Error restoring image: ", error); throw error; diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index c86e99335..db88f3f73 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,25 +1,26 @@ -export function isFileValid(file: File, showAlert = true): boolean { +type TArgs = { + file: File; + maxFileSize: number; +}; + +export const isFileValid = (args: TArgs): boolean => { + const { file, maxFileSize } = args; + if (!file) { - if (showAlert) { - alert("No file selected. Please select a file to upload."); - } + alert("No file selected. Please select a file to upload."); return false; } const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { - if (showAlert) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); - } + alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); return false; } - if (file.size > 5 * 1024 * 1024) { - if (showAlert) { - alert("File size too large. Please select a file smaller than 5MB."); - } + if (file.size > maxFileSize) { + alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`); return false; } return true; -} +}; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 4b706a7f9..8609995ed 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -36,6 +36,7 @@ type TCollaborativeEditorHookProps = { }; export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { + onTransaction?: () => void; embedHandler?: TEmbedConfig; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; @@ -44,5 +45,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { + fileHandler: Pick; forwardedRef?: React.MutableRefObject; }; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 93d612e59..3bb4d1af2 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,10 +1,18 @@ import { DeleteImage, RestoreImage, UploadImage } from "@/types"; export type TFileHandler = { + getAssetSrc: (path: string) => Promise; cancel: () => void; delete: DeleteImage; upload: UploadImage; restore: RestoreImage; + validation: { + /** + * @description max file size in bytes + * @example enter 5242880( 5* 1024 * 1024) for 5MB + */ + maxFileSize: number; + }; }; export type TEditorFontStyle = "sans-serif" | "serif" | "monospace"; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 3624fa046..53aae1f26 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,4 +1,5 @@ import { JSONContent } from "@tiptap/core"; +import { Selection } from "@tiptap/pm/state"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types @@ -7,12 +8,62 @@ import { IMentionSuggestion, TAIHandler, TDisplayConfig, - TEditorCommands, TEmbedConfig, TExtensions, TFileHandler, TServerHandler, } from "@/types"; +import { TTextAlign } from "@/extensions"; + +export type TEditorCommands = + | "text" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "bulleted-list" + | "numbered-list" + | "to-do-list" + | "quote" + | "code" + | "table" + | "image" + | "divider" + | "issue-embed" + | "text-color" + | "background-color" + | "text-align" + | "callout"; + +export type TCommandExtraProps = { + image: { + savedSelection: Selection | null; + }; + "text-color": { + color: string | undefined; + }; + "background-color": { + color: string | undefined; + }; + "text-align": { + alignment: TTextAlign; + }; +}; + +// Create a utility type that maps a command to its extra props or an empty object if none are defined +export type TCommandWithProps = T extends keyof TCommandExtraProps + ? TCommandExtraProps[T] // If the command has extra props, include them + : object; // Otherwise, just return the command type with no extra props + +type TCommandWithPropsWithItemKey = T extends keyof TCommandExtraProps + ? { itemKey: T } & TCommandExtraProps[T] + : { itemKey: T }; // editor refs export type EditorReadOnlyRefApi = { @@ -35,9 +86,12 @@ export type EditorReadOnlyRefApi = { }; export interface EditorRefApi extends EditorReadOnlyRefApi { + blur: () => void; + scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void; + getCurrentCursorPosition: () => number | undefined; setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemKey: TEditorCommands) => void; - isMenuItemActive: (itemKey: TEditorCommands) => boolean; + executeMenuItemCommand: (props: TCommandWithPropsWithItemKey) => void; + isMenuItemActive: (props: TCommandWithPropsWithItemKey) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; @@ -50,6 +104,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { export interface IEditorProps { containerClassName?: string; displayConfig?: TDisplayConfig; + disabledExtensions?: TExtensions[]; editorClassName?: string; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; @@ -60,22 +115,26 @@ export interface IEditorProps { suggestions?: () => Promise; }; onChange?: (json: object, html: string) => void; + onTransaction?: () => void; + handleEditorReady?: (value: boolean) => void; + autofocus?: boolean; onEnterKeyPress?: (e?: any) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; - value?: string | null; + value?: string | null; +} +export interface ILiteTextEditor extends IEditorProps { + extensions?: any[]; } - -export type ILiteTextEditor = IEditorProps; - export interface IRichTextEditor extends IEditorProps { + extensions?: any[]; + bubbleMenuEnabled?: boolean; dragDropEnabled?: boolean; } export interface ICollaborativeDocumentEditor extends Omit { aiHandler?: TAIHandler; - disabledExtensions: TExtensions[]; embedHandler: TEmbedConfig; handleEditorReady?: (value: boolean) => void; id: string; @@ -89,6 +148,7 @@ export interface IReadOnlyEditorProps { containerClassName?: string; displayConfig?: TDisplayConfig; editorClassName?: string; + fileHandler: Pick; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; @@ -119,6 +179,7 @@ export type TUserDetails = { color: string; id: string; name: string; + cookie?: string; }; export type TRealtimeConfig = { diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index da8713f10..2be17a4ef 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -1 +1 @@ -export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed"; +export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands"| "enter-key"; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts index c1b174a48..5c707bf33 100644 --- a/packages/editor/src/core/types/image.ts +++ b/packages/editor/src/core/types/image.ts @@ -1,5 +1,5 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; export type UploadImage = (file: File) => Promise; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 3cb9d76b0..91c93203a 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,27 +1,7 @@ -import { ReactNode } from "react"; +import { CSSProperties } from "react"; import { Editor, Range } from "@tiptap/core"; - -export type TEditorCommands = - | "text" - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "bold" - | "italic" - | "underline" - | "strikethrough" - | "bulleted-list" - | "numbered-list" - | "to-do-list" - | "quote" - | "code" - | "table" - | "image" - | "divider" - | "issue-embed"; +// types +import { TEditorCommands } from "@/types"; export type CommandProps = { editor: Editor; @@ -29,10 +9,12 @@ export type CommandProps = { }; export type ISlashCommandItem = { - key: TEditorCommands; + commandKey: TEditorCommands; + key: string; title: string; description: string; searchTerms: string[]; - icon: ReactNode; + icon: React.ReactNode; + iconContainerStyle?: CSSProperties; command: ({ editor, range }: CommandProps) => void; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fc9fe1ac6..ed7d91346 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,9 +1,10 @@ // styles // import "./styles/tailwind.css"; -import "src/styles/editor.css"; -import "src/styles/table.css"; -import "src/styles/github-dark.css"; -import "src/styles/drag-drop.css"; +import "./styles/variables.css"; +import "./styles/editor.css"; +import "./styles/table.css"; +import "./styles/github-dark.css"; +import "./styles/drag-drop.css"; // editors export { @@ -18,6 +19,9 @@ export { export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +// constants +export * from "@/constants/common"; + // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index e5047fb0c..fff3b533e 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -1,61 +1,3 @@ -.editor-container { - &.large-font { - --font-size-h1: 1.75rem; - --font-size-h2: 1.5rem; - --font-size-h3: 1.375rem; - --font-size-h4: 1.25rem; - --font-size-h5: 1.125rem; - --font-size-h6: 1rem; - --font-size-regular: 1rem; - --font-size-list: var(--font-size-regular); - --font-size-code: var(--font-size-regular); - - --line-height-h1: 2.25rem; - --line-height-h2: 2rem; - --line-height-h3: 1.75rem; - --line-height-h4: 1.5rem; - --line-height-h5: 1.5rem; - --line-height-h6: 1.5rem; - --line-height-regular: 1.5rem; - --line-height-list: var(--line-height-regular); - --line-height-code: var(--line-height-regular); - } - - &.small-font { - --font-size-h1: 1.4rem; - --font-size-h2: 1.2rem; - --font-size-h3: 1.1rem; - --font-size-h4: 1rem; - --font-size-h5: 0.9rem; - --font-size-h6: 0.8rem; - --font-size-regular: 0.8rem; - --font-size-list: var(--font-size-regular); - --font-size-code: var(--font-size-regular); - - --line-height-h1: 1.8rem; - --line-height-h2: 1.6rem; - --line-height-h3: 1.4rem; - --line-height-h4: 1.2rem; - --line-height-h5: 1.2rem; - --line-height-h6: 1.2rem; - --line-height-regular: 1.2rem; - --line-height-list: var(--line-height-regular); - --line-height-code: var(--line-height-regular); - } - - &.sans-serif { - --font-style: sans-serif; - } - - &.serif { - --font-style: serif; - } - - &.monospace { - --font-style: monospace; - } -} - .ProseMirror { position: relative; word-wrap: break-word; @@ -70,7 +12,7 @@ cursor: text; font-family: var(--font-style); font-size: var(--font-size-regular); - line-height: 1.2; + font-weight: 400; color: inherit; -moz-box-sizing: border-box; box-sizing: border-box; @@ -250,7 +192,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { /* Overwrite tippy-box original max-width */ .tippy-box { - max-width: 400px !important; + max-width: 600px !important; } .fade-in { @@ -306,11 +248,6 @@ div[data-type="horizontalRule"] { } } -/* image resizer */ -.moveable-control-box { - z-index: 10 !important; -} - /* Cursor styles for the inline code blocks */ @keyframes blink { 49% { @@ -372,63 +309,108 @@ ul[data-type="taskList"] ul[data-type="taskList"] { } /* end numbered, bulleted and to-do lists spacing */ +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin: 0 !important; +} + /* tailwind typography */ .prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 2rem; - margin-bottom: 4px; + &:not(:first-child) { + padding-top: 28px; + } + + padding-bottom: 4px; font-size: var(--font-size-h1); line-height: var(--line-height-h1); font-weight: 600; } .prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 1.4rem; - margin-bottom: 1px; + &:not(:first-child) { + padding-top: 28px; + } + + padding-bottom: 4px; font-size: var(--font-size-h2); line-height: var(--line-height-h2); font-weight: 600; } .prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 1rem; - margin-bottom: 1px; + &:not(:first-child) { + padding-top: 28px; + } + + padding-bottom: 4px; font-size: var(--font-size-h3); line-height: var(--line-height-h3); font-weight: 600; } .prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 1rem; - margin-bottom: 1px; + &:not(:first-child) { + padding-top: 28px; + } + + padding-bottom: 4px; font-size: var(--font-size-h4); line-height: var(--line-height-h4); font-weight: 600; } .prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 1rem; - margin-bottom: 1px; + &:not(:first-child) { + padding-top: 20px; + } + + padding-bottom: 4px; font-size: var(--font-size-h5); line-height: var(--line-height-h5); font-weight: 600; } .prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 1rem; - margin-bottom: 1px; + &:not(:first-child) { + padding-top: 20px; + } + + padding-bottom: 4px; font-size: var(--font-size-h6); line-height: var(--line-height-h6); font-weight: 600; } .prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - margin-top: 0.25rem; - margin-bottom: 1px; - padding: 3px 0; + &:first-child { + padding-top: 0; + } + + &:not(:first-child) { + padding-top: 4px; + } + + &:last-child { + padding-bottom: 4px; + } + + &:not(:last-child) { + padding-bottom: 8px; + } + font-size: var(--font-size-regular); line-height: var(--line-height-regular); } +p + p { + padding-top: 8px !important; +} + .prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, .prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { font-size: var(--font-size-list); @@ -439,3 +421,57 @@ ul[data-type="taskList"] ul[data-type="taskList"] { margin-top: 0; } /* end tailwind typography */ + +/* text colors */ +[data-text-color="gray"] { + color: var(--editor-colors-gray-text); +} +[data-text-color="peach"] { + color: var(--editor-colors-peach-text); +} +[data-text-color="pink"] { + color: var(--editor-colors-pink-text); +} +[data-text-color="orange"] { + color: var(--editor-colors-orange-text); +} +[data-text-color="green"] { + color: var(--editor-colors-green-text); +} +[data-text-color="light-blue"] { + color: var(--editor-colors-light-blue-text); +} +[data-text-color="dark-blue"] { + color: var(--editor-colors-dark-blue-text); +} +[data-text-color="purple"] { + color: var(--editor-colors-purple-text); +} +/* end text colors */ + +/* background colors */ +[data-background-color="gray"] { + background-color: var(--editor-colors-gray-background); +} +[data-background-color="peach"] { + background-color: var(--editor-colors-peach-background); +} +[data-background-color="pink"] { + background-color: var(--editor-colors-pink-background); +} +[data-background-color="orange"] { + background-color: var(--editor-colors-orange-background); +} +[data-background-color="green"] { + background-color: var(--editor-colors-green-background); +} +[data-background-color="light-blue"] { + background-color: var(--editor-colors-light-blue-background); +} +[data-background-color="dark-blue"] { + background-color: var(--editor-colors-dark-blue-background); +} +[data-background-color="purple"] { + background-color: var(--editor-colors-purple-background); +} +/* end background colors */ diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 2a0140a2b..a0fbbe38f 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -27,10 +27,18 @@ } } -.table-wrapper table th { - font-weight: 500; - text-align: left; - background-color: rgba(var(--color-background-90)); +.table-wrapper table { + th { + font-weight: 500; + text-align: left; + } + + tr[background="none"], + tr:not([background]) { + th { + background-color: rgba(var(--color-background-90)); + } + } } .table-wrapper table .selectedCell { diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css new file mode 100644 index 000000000..d25500692 --- /dev/null +++ b/packages/editor/src/styles/variables.css @@ -0,0 +1,96 @@ +:root { + /* text colors */ + --editor-colors-gray-text: #5c5e63; + --editor-colors-peach-text: #ff5b59; + --editor-colors-pink-text: #f65385; + --editor-colors-orange-text: #fd9038; + --editor-colors-green-text: #0fc27b; + --editor-colors-light-blue-text: #17bee9; + --editor-colors-dark-blue-text: #266df0; + --editor-colors-purple-text: #9162f9; + /* end text colors */ +} + +/* text background colors */ +[data-theme="light"], +[data-theme="light-contrast"] { + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; +} +[data-theme="dark"], +[data-theme="dark-contrast"] { + --editor-colors-gray-background: #404144; + --editor-colors-peach-background: #593032; + --editor-colors-pink-background: #562e3d; + --editor-colors-orange-background: #583e2a; + --editor-colors-green-background: #1d4a3b; + --editor-colors-light-blue-background: #1f495c; + --editor-colors-dark-blue-background: #223558; + --editor-colors-purple-background: #3d325a; +} +/* end text background colors */ + +.editor-container { + /* font sizes and line heights */ + &.large-font { + --font-size-h1: 1.75rem; + --font-size-h2: 1.5rem; + --font-size-h3: 1.375rem; + --font-size-h4: 1.25rem; + --font-size-h5: 1.125rem; + --font-size-h6: 1rem; + --font-size-regular: 1rem; + --font-size-code: 0.85rem; + --font-size-list: var(--font-size-regular); + + --line-height-h1: 2.25rem; + --line-height-h2: 2rem; + --line-height-h3: 1.75rem; + --line-height-h4: 1.5rem; + --line-height-h5: 1.5rem; + --line-height-h6: 1.5rem; + --line-height-regular: 1.5rem; + --line-height-code: 1.5rem; + --line-height-list: var(--line-height-regular); + } + &.small-font { + --font-size-h1: 1.4rem; + --font-size-h2: 1.2rem; + --font-size-h3: 1.1rem; + --font-size-h4: 1rem; + --font-size-h5: 0.9rem; + --font-size-h6: 0.8rem; + --font-size-regular: 0.8rem; + --font-size-code: 0.8rem; + --font-size-list: var(--font-size-regular); + + --line-height-h1: 1.8rem; + --line-height-h2: 1.6rem; + --line-height-h3: 1.4rem; + --line-height-h4: 1.2rem; + --line-height-h5: 1.2rem; + --line-height-h6: 1.2rem; + --line-height-regular: 1.2rem; + --line-height-code: 1.2rem; + --line-height-list: var(--line-height-regular); + } + /* end font sizes and line heights */ + + /* font styles */ + &.sans-serif { + --font-style: "Inter", sans-serif; + } + &.serif { + --font-style: serif; + } + &.monospace { + --font-style: monospace; + } + /* end font styles */ +} diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 335047356..e8de3524c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@plane/eslint-config", "private": true, - "version": "0.23.1", + "version": "0.24.0", "files": [ "library.js", "next.js", diff --git a/packages/helpers/helpers/emoji.helper.ts b/packages/helpers/helpers/emoji.helper.ts new file mode 100644 index 000000000..e0d5a1969 --- /dev/null +++ b/packages/helpers/helpers/emoji.helper.ts @@ -0,0 +1,22 @@ +export const convertHexEmojiToDecimal = (emojiUnified: string): string => { + if (!emojiUnified) return ""; + + return emojiUnified + .toString() + .split("-") + .map((e) => parseInt(e, 16)) + .join("-"); +}; + +export const emojiCodeToUnicode = (emoji: string) => { + if (!emoji) return ""; + + // convert emoji code to unicode + const uniCodeEmoji = emoji + .toString() + .split("-") + .map((emoji) => parseInt(emoji, 10).toString(16)) + .join("-"); + + return uniCodeEmoji; +}; diff --git a/packages/helpers/helpers/index.ts b/packages/helpers/helpers/index.ts new file mode 100644 index 000000000..e800e98fd --- /dev/null +++ b/packages/helpers/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./emoji.helper" +export * from "./string.helper" \ No newline at end of file diff --git a/packages/helpers/helpers/string.helper.ts b/packages/helpers/helpers/string.helper.ts new file mode 100644 index 000000000..aad727262 --- /dev/null +++ b/packages/helpers/helpers/string.helper.ts @@ -0,0 +1,15 @@ +import DOMPurify from "isomorphic-dompurify"; + +/** + * @description: This function will remove all the HTML tags from the string + * @param {string} html + * @return {string} + * @example: + * const html = "

Some text

"; + * const text = stripHTML(html); + * console.log(text); // Some text + */ +export const sanitizeHTML = (htmlString: string) => { + const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags + return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces +}; \ No newline at end of file diff --git a/packages/helpers/index.ts b/packages/helpers/index.ts index 007f69d09..f1216272d 100644 --- a/packages/helpers/index.ts +++ b/packages/helpers/index.ts @@ -1 +1,2 @@ +export * from "./helpers"; export * from "./hooks"; diff --git a/packages/helpers/package.json b/packages/helpers/package.json index b4b94db1f..6e39a19d5 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@plane/helpers", - "version": "0.23.1", + "version": "0.24.0", "description": "Helper functions shared across multiple apps internally", "private": true, "main": "./dist/index.js", @@ -14,11 +14,12 @@ }, "devDependencies": { "@types/node": "^22.5.4", - "@types/react": "^18.3.5", - "typescript": "^5.6.2", - "tsup": "^7.2.0" + "@types/react": "^18.3.11", + "tsup": "^7.2.0", + "typescript": "^5.6.2" }, "dependencies": { + "isomorphic-dompurify": "^2.16.0", "react": "^18.3.1" } } diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index cec6628a6..5c31544b3 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.23.1", + "version": "0.24.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 4f57a3a64..4c8563f5f 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -289,6 +289,7 @@ module.exports = { }, // scale down font sizes to 90% of default fontSize: { + "2xs": "0.5625rem", xs: "0.675rem", sm: "0.7875rem", base: "0.9rem", diff --git a/packages/types/package.json b/packages/types/package.json index 5962ca25c..9ce0fd077 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.23.1", + "version": "0.24.0", "private": true, "types": "./src/index.d.ts", "main": "./src/index.d.ts" diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index 2fb7ad51a..ec417e73f 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -20,7 +20,7 @@ export interface IAnalyticsData { } export interface IAnalyticsAssigneeDetails { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__display_name: string | null; assignees__first_name: string; assignees__id: string | null; @@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData { } export interface IDefaultAnalyticsUser { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__first_name: string; assignees__last_name: string; assignees__display_name: string; @@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse { issue_completed_month_wise: { month: number; count: number }[]; most_issue_closed_user: IDefaultAnalyticsUser[]; most_issue_created_user: { - created_by__avatar: string | null; + created_by__avatar_url: string | null; created_by__first_name: string; created_by__last_name: string; created_by__display_name: string; diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts deleted file mode 100644 index d328f0529..000000000 --- a/packages/types/src/current-user/accounts.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type TCurrentUserAccount = { - id: string | undefined; - - user: string | undefined; - - provider_account_id: string | undefined; - provider: "google" | "github" | "gitlab" | string | undefined; - access_token: string | undefined; - access_token_expired_at: Date | undefined; - refresh_token: string | undefined; - refresh_token_expired_at: Date | undefined; - last_connected_at: Date | undefined; - metadata: object | undefined; - - created_at: Date | undefined; - updated_at: Date | undefined; -}; diff --git a/packages/types/src/current-user/index.ts b/packages/types/src/current-user/index.ts index 43a43b9cd..aeb49bbab 100644 --- a/packages/types/src/current-user/index.ts +++ b/packages/types/src/current-user/index.ts @@ -1,3 +1 @@ -export * from "./user"; export * from "./profile"; -export * from "./accounts"; diff --git a/packages/types/src/current-user/user.d.ts b/packages/types/src/current-user/user.d.ts deleted file mode 100644 index 9bc67b6cf..000000000 --- a/packages/types/src/current-user/user.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type TCurrentUser = { - id: string | undefined; - avatar: string | undefined; - cover_image: string | undefined; - date_joined: Date | undefined; - display_name: string | undefined; - email: string | undefined; - first_name: string | undefined; - last_name: string | undefined; - is_active: boolean; - is_bot: boolean; - is_email_verified: boolean; - is_managed: boolean; - mobile_number: string | undefined; - user_timezone: string | undefined; - username: string | undefined; - is_password_autoset: boolean; -}; - -export type TCurrentUserSettings = { - id: string | undefined; - email: string | undefined; - workspace: { - last_workspace_id: string | undefined; - last_workspace_slug: string | undefined; - fallback_workspace_id: string | undefined; - fallback_workspace_slug: string | undefined; - invites: number | undefined; - }; -}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index fdcffb52b..1c2fa273a 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = { export type TCycleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 3b1c825a0..96efea007 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -1,8 +1,8 @@ import { EDurationFilters } from "./enums"; import { IIssueActivity, TIssuePriorities } from "./issues"; import { TIssue } from "./issues/issue"; -import { TIssueRelationTypes } from "./issues/issue_relation"; import { TStateGroups } from "./state"; +import { TIssueRelationTypes } from "@/plane-web/types"; export type TWidgetKeys = | "overview_stats" diff --git a/packages/types/src/de-dupe.d.ts b/packages/types/src/de-dupe.d.ts new file mode 100644 index 000000000..539a151a7 --- /dev/null +++ b/packages/types/src/de-dupe.d.ts @@ -0,0 +1,24 @@ +import { TIssuePriorities } from "./issues"; + +export type TDuplicateIssuePayload = { + title: string; + workspace_id: string; + issue_id?: string; + project_id?: string; + description_stripped?: string; +}; + +export type TDeDupeIssue = { + id: string; + type_id: string | null; + project_id: string; + sequence_id: number; + name: string; + priority: TIssuePriorities; + state_id: string; + created_by: string; +}; + +export type TDuplicateIssueResponse = { + dupes: TDeDupeIssue[]; +}; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 914ebb0c3..df6a462b0 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -48,3 +48,15 @@ export enum ENotificationFilterType { ASSIGNED = "assigned", SUBSCRIBED = "subscribed", } + +export enum EFileAssetType { + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION", + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT", + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION", + DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION", + PAGE_DESCRIPTION = "PAGE_DESCRIPTION", + PROJECT_COVER = "PROJECT_COVER", + USER_AVATAR = "USER_AVATAR", + USER_COVER = "USER_COVER", + WORKSPACE_LOGO = "WORKSPACE_LOGO", +} diff --git a/packages/types/src/file.d.ts b/packages/types/src/file.d.ts new file mode 100644 index 000000000..8bcaade6c --- /dev/null +++ b/packages/types/src/file.d.ts @@ -0,0 +1,32 @@ +import { EFileAssetType } from "./enums" + +export type TFileMetaDataLite = { + name: string; + // file size in bytes + size: number; + type: string; +} + +export type TFileEntityInfo = { + entity_identifier: string; + entity_type: EFileAssetType; +} + +export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo; + +export type TFileSignedURLResponse = { + asset_id: string; + asset_url: string; + upload_data: { + url: string; + fields: { + "Content-Type": string; + key: string; + "x-amz-algorithm": string; + "x-amz-credential": string; + "x-amz-date": string; + policy: string; + "x-amz-signature": string; + }; + }; +}; \ No newline at end of file diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index afb744f6c..5ae6c160e 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -96,3 +96,17 @@ export type TInboxIssuePaginationInfo = TPaginationInfo & { export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & { results: TInboxIssue[]; }; + +export type TInboxForm = { + anchor: string; + id: string; + is_in_app_enabled: boolean; + is_form_enabled: boolean; +}; + +export type TInboxIssueForm = { + name: string; + description: string; + username: string; + email: string; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6dfddc6b6..10e519700 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,6 +2,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; +export * from "./de-dupe"; export * from "./project"; export * from "./state"; export * from "./issues"; @@ -29,3 +30,5 @@ export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; export * from "./favorite"; +export * from "./file"; +export * from "./workspace-draft-issues/base"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 8095f4e01..41198b27c 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -4,6 +4,7 @@ import { TInstanceEmailConfigurationKeys, TInstanceImageConfigurationKeys, TInstanceAuthenticationKeys, + TInstanceWorkspaceConfigurationKeys, } from "./"; export interface IInstanceInfo { @@ -36,6 +37,7 @@ export interface IInstance { } export interface IInstanceConfig { + is_workspace_creation_disabled: boolean; is_google_enabled: boolean; is_github_enabled: boolean; is_gitlab_enabled: boolean; @@ -78,7 +80,8 @@ export type TInstanceConfigurationKeys = | TInstanceEmailConfigurationKeys | TInstanceImageConfigurationKeys | TInstanceAuthenticationKeys - | TInstanceIntercomConfigurationKeys; + | TInstanceIntercomConfigurationKeys + | TInstanceWorkspaceConfigurationKeys; export interface IInstanceConfiguration { id: string; diff --git a/packages/types/src/instance/index.d.ts b/packages/types/src/instance/index.d.ts index c68f196d3..bc6474b83 100644 --- a/packages/types/src/instance/index.d.ts +++ b/packages/types/src/instance/index.d.ts @@ -3,3 +3,4 @@ export * from "./auth"; export * from "./base"; export * from "./email"; export * from "./image"; +export * from "./workspace"; diff --git a/packages/types/src/instance/workspace.d.ts b/packages/types/src/instance/workspace.d.ts new file mode 100644 index 000000000..15a7317d0 --- /dev/null +++ b/packages/types/src/instance/workspace.d.ts @@ -0,0 +1 @@ +export type TInstanceWorkspaceConfigurationKeys = "DISABLE_WORKSPACE_CREATION"; diff --git a/packages/types/src/integration.d.ts b/packages/types/src/integration.d.ts index bb76f9fc0..e2561bd18 100644 --- a/packages/types/src/integration.d.ts +++ b/packages/types/src/integration.d.ts @@ -1,7 +1,6 @@ // All the app integrations that are available export interface IAppIntegration { author: string; - author: ""; avatar_url: string | null; created_at: string; created_by: string | null; diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 82b881fd9..63f365d89 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = { id: string; first_name: string; last_name: string; - avatar: string; + avatar_url: string; is_bot: boolean; display_name: string; }; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 8292c1116..05f679cce 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -10,6 +10,7 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; + export type TLoader = | "init-loader" | "mutation" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 1584a3d16..b9366cccb 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -2,6 +2,7 @@ import { TIssuePriorities } from "../issues"; import { TIssueAttachment } from "./issue_attachment"; import { TIssueLink } from "./issue_link"; import { TIssueReaction } from "./issue_reaction"; +import { TIssueRelationTypes } from "@/plane-web/types"; // new issue structure types @@ -40,13 +41,23 @@ export type TBaseIssue = { is_draft: boolean; }; +export type IssueRelation = { + id: string; + name: string; + project_id: string; + relation_type: TIssueRelationTypes; + sequence_id: number; +}; + export type TIssue = TBaseIssue & { description_html?: string; is_subscribed?: boolean; parent?: Partial; issue_reactions?: TIssueReaction[]; - issue_attachment?: TIssueAttachment[]; + issue_attachments?: TIssueAttachment[]; issue_link?: TIssueLink[]; + issue_relation?: IssueRelation[]; + issue_related?: IssueRelation[]; // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; // sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response. @@ -84,6 +95,7 @@ export type TIssuesResponse = { total_pages: number; extra_stats: null; results: TIssueResponseResults; + total_results: number; }; export type TBulkIssueProperties = Pick< diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 7c3819e00..2238fa4c7 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -1,17 +1,22 @@ +import { TFileSignedURLResponse } from "../file"; + export type TIssueAttachment = { id: string; attributes: { name: string; size: number; }; - asset: string; + asset_url: string; issue_id: string; - - //need + // required updated_at: string; updated_by: string; }; +export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & { + attachment: TIssueAttachment +}; + export type TIssueAttachmentMap = { [issue_id: string]: TIssueAttachment; }; diff --git a/packages/types/src/issues/issue_relation.d.ts b/packages/types/src/issues/issue_relation.d.ts index 0b1c5f7cd..378470a58 100644 --- a/packages/types/src/issues/issue_relation.d.ts +++ b/packages/types/src/issues/issue_relation.d.ts @@ -1,11 +1,6 @@ +import { TIssueRelationTypes } from "@/plane-web/types"; import { TIssue } from "./issues"; -export type TIssueRelationTypes = - | "blocking" - | "blocked_by" - | "duplicate" - | "relates_to"; - export type TIssueRelation = Record; export type TIssueRelationMap = { diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 6a5a09231..fa77a6a41 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = { export type TModuleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index a46f490f1..d48342ceb 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -18,7 +18,11 @@ export interface IProject { close_in: number; created_at: Date; created_by: string; - cover_image: string | null; + // only for uploading the cover image + cover_image_asset?: null; + cover_image?: string; + // only for rendering the cover image + cover_image_url: readonly string; cycle_view: boolean; issue_views_view: boolean; module_view: boolean; @@ -54,6 +58,7 @@ export interface IProject { updated_by: string; workspace: IWorkspace | string; workspace_detail: IWorkspaceLite; + timezone: string; } export interface IProjectLite { @@ -75,7 +80,7 @@ export interface IProjectMap { export interface IProjectMemberLite { id: string; - member__avatar: string; + member__avatar_url: string; member__display_name: string; member_id: string; } diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 4d5db28f9..452bc23c2 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -3,17 +3,24 @@ import { TUserPermissions } from "./enums"; type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; -export interface IUser { - id: string; - avatar: string | null; - cover_image: string | null; - date_joined: string; +export interface IUserLite { + avatar_url: string; display_name: string; - email: string; + email?: string; first_name: string; - last_name: string; - is_active: boolean; + id: string; is_bot: boolean; + last_name: string; +} +export interface IUser extends IUserLite { + // only for uploading the cover image + cover_image_asset?: string | null; + cover_image?: string | null; + // only for rendering the cover image + cover_image_url: readonly (string | null); + date_joined: string; + email: string; + is_active: boolean; is_email_verified: boolean; is_password_autoset: boolean; is_tour_completed: boolean; @@ -86,16 +93,6 @@ export interface IUserTheme { sidebarBackground: string | undefined; } -export interface IUserLite { - avatar: string; - display_name: string; - email?: string; - first_name: string; - id: string; - is_bot: boolean; - last_name: string; -} - export interface IUserMemberLite extends IUserLite { email?: string; } @@ -158,13 +155,15 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: { - avatar: string; - cover_image: string | null; + user_data: Pick< + IUser, + | "avatar_url" + | "cover_image_url" + | "display_name" + | "first_name" + | "last_name" + > & { date_joined: Date; - display_name: string; - first_name: string; - last_name: string; user_timezone: string; }; } diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 59d5ffded..57baa4cfd 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -40,8 +40,8 @@ export type TIssueOrderByOptions = | "-issue_cycle__cycle__name" | "target_date" | "-target_date" - | "estimate_point" - | "-estimate_point" + | "estimate_point__key" + | "-estimate_point__key" | "start_date" | "-start_date" | "link_count" @@ -77,7 +77,9 @@ export type TIssueParams = | "show_empty_groups" | "cursor" | "per_page" - | "issue_type"; + | "issue_type" + | "layout" + | "expand"; export type TCalendarLayouts = "month" | "week"; diff --git a/packages/types/src/workspace-draft-issues/base.d.ts b/packages/types/src/workspace-draft-issues/base.d.ts new file mode 100644 index 000000000..8090a9cb7 --- /dev/null +++ b/packages/types/src/workspace-draft-issues/base.d.ts @@ -0,0 +1,63 @@ +import { TIssuePriorities } from "../issues"; + +export type TWorkspaceDraftIssue = { + id: string; + name: string; + sort_order: number; + + state_id: string | undefined; + priority: TIssuePriorities | undefined; + label_ids: string[]; + assignee_ids: string[]; + estimate_point: string | undefined; + + project_id: string | undefined; + parent_id: string | undefined; + cycle_id: string | undefined; + module_ids: string[] | undefined; + + start_date: string | undefined; + target_date: string | undefined; + completed_at: string | undefined; + + created_at: string; + updated_at: string; + created_by: string; + updated_by: string; + + is_draft: boolean; + + type_id: string; +}; + +export type TWorkspaceDraftPaginationInfo = { + next_cursor: string | undefined; + prev_cursor: string | undefined; + next_page_results: boolean | undefined; + prev_page_results: boolean | undefined; + total_pages: number | undefined; + count: number | undefined; // current paginated results count + total_count: number | undefined; // total available results count + total_results: number | undefined; + results: T[] | undefined; + extra_stats: string | undefined; + grouped_by: string | undefined; + sub_grouped_by: string | undefined; +}; + +export type TWorkspaceDraftQueryParams = { + per_page: number; + cursor: string; +}; + +export type TWorkspaceDraftIssueLoader = + | "init-loader" + | "empty-state" + | "mutation" + | "pagination" + | "loaded" + | "create" + | "update" + | "delete" + | "move" + | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index f72f52463..500eaa7b5 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -14,14 +14,14 @@ export interface IWorkspace { readonly updated_at: Date; name: string; url: string; - logo: string | null; - slug: string; + logo_url: string | null; readonly total_members: number; readonly slug: string; readonly created_by: string; readonly updated_by: string; organization_size: string; total_issues: number; + total_projects?: number; } export interface IWorkspaceLite { @@ -71,7 +71,7 @@ export interface IWorkspaceMember { member: IUserLite; role: TUserPermissions; created_at?: string; - avatar?: string; + avatar_url?: string; email?: string; first_name?: string; last_name?: string; @@ -92,6 +92,7 @@ export interface IWorkspaceMemberMe { updated_by: string; view_props: IWorkspaceViewProps; workspace: string; + draft_issue_count: number; } export interface ILastActiveWorkspaceDetails { @@ -222,3 +223,7 @@ export interface IWorkspaceProgressResponse { export interface IWorkspaceAnalyticsResponse { completion_chart: any; } + +export type TWorkspacePaginationInfo = TPaginationInfo & { + results: IWorkspace[]; +}; diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index f6b12920c..243877366 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "0.23.1", + "version": "0.24.0", "private": true, "files": [ "base.json", diff --git a/packages/ui/package.json b/packages/ui/package.json index 09019457a..27189f606 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.23.1", + "version": "0.24.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -20,6 +20,10 @@ "postcss": "postcss styles/globals.css -o styles/output.css --watch", "lint": "eslint src --ext .ts,.tsx" }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.1.10", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", @@ -33,7 +37,6 @@ "lodash": "^4.17.21", "lucide-react": "^0.379.0", "react-color": "^2.19.3", - "react-dom": "^18.2.0", "react-popper": "^2.3.0", "sonner": "^1.4.41", "tailwind-merge": "^2.0.0", @@ -53,15 +56,14 @@ "@storybook/test": "^8.1.1", "@types/lodash": "^4.17.6", "@types/node": "^20.5.2", - "@types/react": "^18.2.42", + "@types/react": "^18.3.11", "@types/react-color": "^3.0.9", - "@types/react-dom": "^18.2.17", + "@types/react-dom": "^18.2.18", "autoprefixer": "^10.4.19", "classnames": "^2.3.2", "@plane/eslint-config": "*", "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", - "react": "^18.2.0", "storybook": "^8.1.1", "tailwind-config-custom": "*", "tailwindcss": "^3.4.3", diff --git a/packages/ui/src/emoji/emoji-icon-helper.tsx b/packages/ui/src/emoji/emoji-icon-helper.tsx index 533f025d1..4643dcb93 100644 --- a/packages/ui/src/emoji/emoji-icon-helper.tsx +++ b/packages/ui/src/emoji/emoji-icon-helper.tsx @@ -43,6 +43,7 @@ export type TCustomEmojiPicker = { label: React.ReactNode; onChange: (value: TChangeHandlerProps) => void; placement?: Placement; + searchDisabled?: boolean; searchPlaceholder?: string; theme?: Theme; iconType?: "material" | "lucide"; @@ -53,6 +54,7 @@ export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b export type TIconsListProps = { defaultColor: string; onChange: (val: { name: string; color: string }) => void; + searchDisabled?: boolean; }; /** diff --git a/packages/ui/src/emoji/emoji-icon-picker-new.tsx b/packages/ui/src/emoji/emoji-icon-picker-new.tsx index 4a4c64eda..9c56cf280 100644 --- a/packages/ui/src/emoji/emoji-icon-picker-new.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker-new.tsx @@ -25,6 +25,7 @@ export const EmojiIconPicker: React.FC = (props) => { label, onChange, placement = "bottom-start", + searchDisabled = false, searchPlaceholder = "Search", theme, } = props; @@ -107,10 +108,12 @@ export const EmojiIconPicker: React.FC = (props) => { height="20rem" width="100%" theme={theme} + searchDisabled={searchDisabled} searchPlaceholder={searchPlaceholder} previewConfig={{ showPreview: false, }} + lazyLoadEmojis /> @@ -123,6 +126,7 @@ export const EmojiIconPicker: React.FC = (props) => { }); if (closeOnSelect) handleToggle(false); }} + searchDisabled={searchDisabled} /> diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx index 8420570f2..678a32243 100644 --- a/packages/ui/src/emoji/emoji-icon-picker.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -25,6 +25,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { label, onChange, placement = "bottom-start", + searchDisabled = false, searchPlaceholder = "Search", theme, } = props; @@ -107,6 +108,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { height="20rem" width="100%" theme={theme} + searchDisabled={searchDisabled} searchPlaceholder={searchPlaceholder} previewConfig={{ showPreview: false, @@ -123,6 +125,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { }); if (closeOnSelect) handleToggle(false); }} + searchDisabled={searchDisabled} /> diff --git a/packages/ui/src/emoji/helpers.ts b/packages/ui/src/emoji/helpers.ts index faa8ebdd9..aa04f8603 100644 --- a/packages/ui/src/emoji/helpers.ts +++ b/packages/ui/src/emoji/helpers.ts @@ -3,6 +3,7 @@ export const emojiCodeToUnicode = (emoji: string) => { // convert emoji code to unicode const uniCodeEmoji = emoji + .toString() .split("-") .map((emoji) => parseInt(emoji, 10).toString(16)) .join("-"); diff --git a/packages/ui/src/emoji/icons-list.tsx b/packages/ui/src/emoji/icons-list.tsx index e0bb87b37..bb7607489 100644 --- a/packages/ui/src/emoji/icons-list.tsx +++ b/packages/ui/src/emoji/icons-list.tsx @@ -12,7 +12,7 @@ import { cn } from "../../helpers"; import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper"; export const IconsList: React.FC = (props) => { - const { defaultColor, onChange } = props; + const { defaultColor, onChange, searchDisabled = false } = props; // states const [activeColor, setActiveColor] = useState(defaultColor); const [showHexInput, setShowHexInput] = useState(false); @@ -42,21 +42,23 @@ export const IconsList: React.FC = (props) => { return ( <>
-
-
setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - > - - setQuery(e.target.value)} - className="text-[1rem] border-none p-0 h-full w-full " - /> + {!searchDisabled && ( +
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + + setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " + /> +
-
+ )}
{showHexInput ? (
diff --git a/packages/ui/src/emoji/logo.tsx b/packages/ui/src/emoji/logo.tsx index 528e16047..74d226dfa 100644 --- a/packages/ui/src/emoji/logo.tsx +++ b/packages/ui/src/emoji/logo.tsx @@ -6,7 +6,7 @@ import { LUCIDE_ICONS_LIST } from "./icons"; // helpers import { emojiCodeToUnicode } from "./helpers"; -type TLogoProps = { +export type TEmojiLogoProps = { in_use: "emoji" | "icon"; emoji?: { value?: string; @@ -19,7 +19,7 @@ type TLogoProps = { }; type Props = { - logo: TLogoProps; + logo: TEmojiLogoProps; size?: number; type?: "lucide" | "material"; }; diff --git a/packages/ui/src/emoji/lucide-icons-list.tsx b/packages/ui/src/emoji/lucide-icons-list.tsx index 5ffda34e6..d2fe95ecf 100644 --- a/packages/ui/src/emoji/lucide-icons-list.tsx +++ b/packages/ui/src/emoji/lucide-icons-list.tsx @@ -11,7 +11,7 @@ import { LUCIDE_ICONS_LIST } from "./icons"; import { Search } from "lucide-react"; export const LucideIconsList: React.FC = (props) => { - const { defaultColor, onChange } = props; + const { defaultColor, onChange, searchDisabled = false } = props; // states const [activeColor, setActiveColor] = useState(defaultColor); const [showHexInput, setShowHexInput] = useState(false); @@ -32,21 +32,23 @@ export const LucideIconsList: React.FC = (props) => { return ( <>
-
-
setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - > - - setQuery(e.target.value)} - className="text-[1rem] border-none p-0 h-full w-full " - /> + {!searchDisabled && ( +
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + + setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " + /> +
-
+ )}
{showHexInput ? (
@@ -104,8 +106,8 @@ export const LucideIconsList: React.FC = (props) => {
- -

Colors will be adjusted to ensure sufficient contrast.

+ +

Colors will be adjusted to ensure sufficient contrast.

diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index 48cc311e3..786e00a1d 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -5,7 +5,7 @@ import { cn } from "../../helpers"; import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea"; export interface TextAreaProps extends React.TextareaHTMLAttributes { - mode?: "primary" | "transparent"; + mode?: "primary" | "transparent" | "true-transparent"; textAreaSize?: "xs" | "sm" | "md"; hasError?: boolean; className?: string; @@ -39,6 +39,7 @@ const TextArea = React.forwardRef((props, re "rounded-md border-[0.5px] border-custom-border-200": mode === "primary", "focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1": mode === "transparent", + "rounded border-none bg-transparent ring-0": mode === "true-transparent", "px-1.5 py-1": textAreaSize === "xs", "px-3 py-2": textAreaSize === "sm", "p-3": textAreaSize === "md", diff --git a/packages/ui/src/header/helper.tsx b/packages/ui/src/header/helper.tsx index b6d76f8c6..13fee8b47 100644 --- a/packages/ui/src/header/helper.tsx +++ b/packages/ui/src/header/helper.tsx @@ -10,9 +10,11 @@ export interface IHeaderProperties { } export const headerStyle: IHeaderProperties = { [EHeaderVariant.PRIMARY]: - "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-background-100 z-[18]", - [EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]", - [EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]", + "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-sidebar-background-100 z-[18]", + [EHeaderVariant.SECONDARY]: + "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]", + [EHeaderVariant.TERNARY]: + "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]", }; export const minHeights: IHeaderProperties = { [EHeaderVariant.PRIMARY]: "", diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 69436c2e8..91ae0e2f1 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -31,3 +31,6 @@ export * from "./favorite-folder-icon"; export * from "./planned-icon"; export * from "./in-progress-icon"; export * from "./done-icon"; +export * from "./pending-icon"; +export * from "./pi-chat"; +export * from "./workspace-icon"; diff --git a/packages/ui/src/icons/pending-icon.tsx b/packages/ui/src/icons/pending-icon.tsx new file mode 100644 index 000000000..5269a22e2 --- /dev/null +++ b/packages/ui/src/icons/pending-icon.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const PendingState: React.FC = ({ width = "10", height = "11", className, color = "#455068" }) => ( + + + + +); diff --git a/packages/ui/src/icons/pi-chat.tsx b/packages/ui/src/icons/pi-chat.tsx new file mode 100644 index 000000000..e2e49a28f --- /dev/null +++ b/packages/ui/src/icons/pi-chat.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const PiChatLogo: React.FC = ({ width = "16", height = "16", className, color = "currentColor" }) => ( + + + + + +); diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index ffa74a374..b2fb2d534 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -16,7 +16,7 @@ export const PriorityIcon: React.FC = (props) => { const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props; const priorityClasses = { - urgent: "bg-red-600 text-red-500 border-red-600", + urgent: "bg-red-600/20 text-red-600 border-red-600", high: "bg-orange-500/20 text-orange-500 border-orange-500", medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500", low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100", @@ -49,8 +49,6 @@ export const PriorityIcon: React.FC = (props) => { size={size} className={cn( { - "text-white": priority === "urgent", - // centre align the icons "translate-x-[0.0625rem]": priority === "high", "translate-x-0.5": priority === "medium", "translate-x-1": priority === "low", diff --git a/packages/ui/src/icons/workspace-icon.tsx b/packages/ui/src/icons/workspace-icon.tsx new file mode 100644 index 000000000..e15d33f30 --- /dev/null +++ b/packages/ui/src/icons/workspace-icon.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const WorkspaceIcon: React.FC = ({ className }) => ( + + + +); diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts index 0cb268fc8..fe72ef7ae 100644 --- a/packages/ui/src/modals/constants.ts +++ b/packages/ui/src/modals/constants.ts @@ -4,6 +4,9 @@ export enum EModalPosition { } export enum EModalWidth { + SM = "sm:max-w-sm", + MD = "sm:max-w-md", + LG = "sm:max-w-lg", XL = "sm:max-w-xl", XXL = "sm:max-w-2xl", XXXL = "sm:max-w-3xl", diff --git a/packages/ui/src/popovers/popover.tsx b/packages/ui/src/popovers/popover.tsx index 30a168965..4860a25a8 100644 --- a/packages/ui/src/popovers/popover.tsx +++ b/packages/ui/src/popovers/popover.tsx @@ -18,6 +18,7 @@ export const Popover = (props: TPopover) => { panelClassName = "", children, popoverButtonRef, + buttonRefClassName = "", } = props; // states const [referenceElement, setReferenceElement] = useState(null); @@ -38,7 +39,7 @@ export const Popover = (props: TPopover) => { return ( -
+
} className={cn( diff --git a/packages/ui/src/popovers/types.ts b/packages/ui/src/popovers/types.ts index 51b1e877a..7801e2d85 100644 --- a/packages/ui/src/popovers/types.ts +++ b/packages/ui/src/popovers/types.ts @@ -5,6 +5,7 @@ export type TPopoverButtonDefaultOptions = { // button and button styling button?: ReactNode; buttonClassName?: string; + buttonRefClassName?: string; disabled?: boolean; }; diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css index 79eb9e448..43338b7a9 100644 --- a/packages/ui/styles/globals.css +++ b/packages/ui/styles/globals.css @@ -101,6 +101,19 @@ --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* pi */ + --color-pi-50: var(--color-background-90); + --color-pi-100: var(--color-background-90); + --color-pi-200: var(--color-primary-200); + --color-pi-300: var(--color-primary-200); + --color-pi-400: var(--color-primary-200); + --color-pi-500: var(--color-primary-200); + --color-pi-600: 151, 150, 246; + --color-pi-700: var(--color-primary-100); + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"], @@ -110,6 +123,19 @@ --color-background-100: 255, 255, 255; /* primary bg */ --color-background-90: 247, 247, 247; /* secondary bg */ --color-background-80: 232, 232, 232; /* tertiary bg */ + + /* pi */ + --color-pi-50: var(--color-background-90); + --color-pi-100: var(--color-background-90); + --color-pi-200: var(--color-primary-200); + --color-pi-300: var(--color-primary-200); + --color-pi-400: var(--color-primary-200); + --color-pi-500: var(--color-primary-200); + --color-pi-600: 151, 150, 246; + --color-pi-700: var(--color-primary-100); + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"] { @@ -200,6 +226,18 @@ --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + /* pi */ + --color-pi-50: var(--color-background-90); + --color-pi-100: var(--color-background-90); + --color-pi-200: var(--color-primary-200); + --color-pi-300: var(--color-primary-200); + --color-pi-400: var(--color-primary-200); + --color-pi-500: var(--color-primary-200); + --color-pi-600: 151, 150, 246; + --color-pi-700: var(--color-primary-100); + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="dark"] { diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 186f44a10..0e3f34293 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -5,46 +5,47 @@ import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/edi import { IssueCommentToolbar } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -// services -import fileService from "@/services/file.service"; interface LiteTextEditorWrapperProps extends Omit { - workspaceSlug: string; + anchor: string; workspaceId: string; isSubmitting?: boolean; showSubmitButton?: boolean; + uploadFile: (file: File) => Promise; } export const LiteTextEditor = React.forwardRef((props, ref) => { const { + anchor, containerClassName, - workspaceSlug, workspaceId, isSubmitting = false, showSubmitButton = true, + uploadFile, ...rest } = props; // use-mention const { mentionHighlights } = useMention(); - function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } + // derived values const isEmpty = isCommentEmpty(props.initialValue); + const editorRef = isMutableRefObject(ref) ? ref.current : null; return (
{ - if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); - } + executeCommand={(item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); }} isSubmitting={isSubmitting} showSubmitButton={showSubmitButton} handleSubmit={(e) => rest.onEnterKeyPress?.(e)} isCommentEmpty={isEmpty} - editorRef={isMutableRefObject(ref) ? ref : null} + editorRef={editorRef} />
); diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 033b98ccd..e12a46682 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -3,18 +3,24 @@ import React from "react"; import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -type LiteTextReadOnlyEditorWrapperProps = Omit; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( { + uploadFile: (file: File) => Promise; +} + +export const RichTextEditor = forwardRef((props, ref) => { + const { containerClassName, uploadFile, ...rest } = props; + // store hooks + + // use-mention + + // file size + + return ( + { + throw new Error("Function not implemented."); + }, + suggestions: undefined, + }} + ref={ref} + fileHandler={getEditorFileHandlers({ + uploadFile, + workspaceId: "", + anchor: "", + })} + {...rest} + containerClassName={containerClassName} + editorClassName="min-h-[100px] max-h-[50vh] border border-gray-100 rounded-md pl-3 pb-3 overflow-y-scroll" + /> + ); +}); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/space/core/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx index 9994db477..b5b097baa 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -3,18 +3,24 @@ import React from "react"; import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -type RichTextReadOnlyEditorWrapperProps = Omit; +type RichTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( void; + executeCommand: (item: ToolbarMenuItem) => void; handleSubmit: (event: React.MouseEvent) => void; isCommentEmpty: boolean; isSubmitting: boolean; showSubmitButton: boolean; - editorRef: React.MutableRefObject | null; + editorRef: EditorRefApi | null; }; const toolbarItems = TOOLBAR_ITEMS.lite; @@ -28,22 +28,25 @@ export const IssueCommentToolbar: React.FC = (props) => { // Function to update active states const updateActiveStates = useCallback(() => { - if (editorRef?.current) { - const newActiveStates: Record = {}; - Object.values(toolbarItems) - .flat() - .forEach((item) => { - // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, }); - setActiveStates(newActiveStates); - } + }); + setActiveStates(newActiveStates); }, [editorRef]); // useEffect to call updateActiveStates when isActive prop changes useEffect(() => { - if (!editorRef?.current) return; - const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); updateActiveStates(); return () => unsubscribe(); }, [editorRef, updateActiveStates]); @@ -59,35 +62,39 @@ export const IssueCommentToolbar: React.FC = (props) => { "pl-0": index === 0, })} > - {toolbarItems[key].map((item) => ( - - {item.name} - {item.shortcut && {item.shortcut.join(" + ")}} -

- } - > - -
- ))} + + + ); + })}
))}
diff --git a/space/core/components/issues/navbar/user-avatar.tsx b/space/core/components/issues/navbar/user-avatar.tsx index 9c1f3311d..40339bb5c 100644 --- a/space/core/components/issues/navbar/user-avatar.tsx +++ b/space/core/components/issues/navbar/user-avatar.tsx @@ -10,6 +10,7 @@ import { Popover, Transition } from "@headlessui/react"; import { Avatar, Button } from "@plane/ui"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useUser } from "@/hooks/store"; @@ -66,7 +67,7 @@ export const UserAvatar: FC = observer(() => { > = observer((props) => { const { anchor } = props; + // states + const [uploadedAssetIds, setUploadAssetIds] = useState([]); // refs const editorRef = useRef(null); // store hooks - const { peekId: issueId, addIssueComment } = useIssueDetails(); + const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); // form info const { handleSubmit, @@ -44,9 +49,15 @@ export const AddComment: React.FC = observer((props) => { if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; await addIssueComment(anchor, issueId, formData) - .then(() => { + .then(async (res) => { reset(defaultValues); editorRef.current?.clearEditor(); + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkAssetsUploadStatus(anchor, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadAssetIds([]); + } }) .catch(() => setToast({ @@ -69,8 +80,8 @@ export const AddComment: React.FC = observer((props) => { onEnterKeyPress={(e) => { if (currentUser) handleSubmit(onSubmit)(e); }} + anchor={anchor} workspaceId={workspaceID?.toString() ?? ""} - workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} id="peek-overview-add-comment" initialValue={ @@ -80,7 +91,12 @@ export const AddComment: React.FC = observer((props) => { } onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} - placeholder="Add Comment..." + placeholder="Add comment..." + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor); + setUploadAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 47b506b96..1b228dfb3 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -9,6 +9,7 @@ import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; @@ -23,9 +24,9 @@ type Props = { export const CommentCard: React.FC = observer((props) => { const { anchor, comment } = props; // store hooks - const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); + const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); const isInIframe = useIsInIframe(); // states @@ -58,10 +59,10 @@ export const CommentCard: React.FC = observer((props) => { return (
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( // eslint-disable-next-line @next/next/no-img-element { = observer((props) => { name="comment_html" render={({ field: { onChange, value } }) => ( = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); + return asset_id; + }} /> )} /> @@ -133,7 +138,12 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index b47bfad68..36bad2fad 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => {

{issueDetails.name}

{description !== "" && description !== "

" && ( = T extends keyof TCommandExtraProps + ? TCommandExtraProps[T] + : object; // Default to empty object for commands without extra props + +export type ToolbarMenuItem = { + itemKey: T; + renderKey: string; name: string; icon: LucideIcon; shortcut?: string[]; editors: TEditorTypes[]; + extraProps?: ExtraPropsForCommand; }; -export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ - { key: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, - { key: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, - { key: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, - { key: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, - { key: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, - { key: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, - { key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] }, - { key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] }, - { key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] }, +export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [ + { itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] }, + { itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, + { itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, + { itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, + { itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, + { itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, +]; + +export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [ { - key: "strikethrough", + itemKey: "text-align", + renderKey: "text-align-left", + name: "Left align", + icon: AlignLeft, + shortcut: ["Cmd", "Shift", "L"], + editors: ["lite", "document"], + extraProps: { + alignment: "left", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-center", + name: "Center align", + icon: AlignCenter, + shortcut: ["Cmd", "Shift", "E"], + editors: ["lite", "document"], + extraProps: { + alignment: "center", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-right", + name: "Right align", + icon: AlignRight, + shortcut: ["Cmd", "Shift", "R"], + editors: ["lite", "document"], + extraProps: { + alignment: "right", + }, + }, +]; + +const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [ + { + itemKey: "bold", + renderKey: "bold", + name: "Bold", + icon: Bold, + shortcut: ["Cmd", "B"], + editors: ["lite", "document"], + }, + { + itemKey: "italic", + renderKey: "italic", + name: "Italic", + icon: Italic, + shortcut: ["Cmd", "I"], + editors: ["lite", "document"], + }, + { + itemKey: "underline", + renderKey: "underline", + name: "Underline", + icon: Underline, + shortcut: ["Cmd", "U"], + editors: ["lite", "document"], + }, + { + itemKey: "strikethrough", + renderKey: "strikethrough", name: "Strikethrough", icon: Strikethrough, shortcut: ["Cmd", "Shift", "S"], @@ -50,23 +123,26 @@ export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ }, ]; -export const LIST_ITEMS: ToolbarMenuItem[] = [ +const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [ { - key: "bulleted-list", + itemKey: "bulleted-list", + renderKey: "bulleted-list", name: "Bulleted list", icon: List, shortcut: ["Cmd", "Shift", "7"], editors: ["lite", "document"], }, { - key: "numbered-list", + itemKey: "numbered-list", + renderKey: "numbered-list", name: "Numbered list", icon: ListOrdered, shortcut: ["Cmd", "Shift", "8"], editors: ["lite", "document"], }, { - key: "to-do-list", + itemKey: "to-do-list", + renderKey: "to-do-list", name: "To-do list", icon: ListTodo, shortcut: ["Cmd", "Shift", "9"], @@ -74,14 +150,14 @@ export const LIST_ITEMS: ToolbarMenuItem[] = [ }, ]; -export const USER_ACTION_ITEMS: ToolbarMenuItem[] = [ - { key: "quote", name: "Quote", icon: Quote, editors: ["lite", "document"] }, - { key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, +export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ + { itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] }, + { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, ]; -export const COMPLEX_ITEMS: ToolbarMenuItem[] = [ - { key: "table", name: "Table", icon: Table, editors: ["document"] }, - { key: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, +export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ + { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, + { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, ]; export const TOOLBAR_ITEMS: { @@ -91,12 +167,14 @@ export const TOOLBAR_ITEMS: { } = { lite: { basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")), list: LIST_ITEMS.filter((item) => item.editors.includes("lite")), userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")), complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")), }, document: { basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")), list: LIST_ITEMS.filter((item) => item.editors.includes("document")), userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")), complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")), diff --git a/space/core/services/api.service.ts b/space/core/services/api.service.ts index e13ebbdcc..ff5af7aca 100644 --- a/space/core/services/api.service.ts +++ b/space/core/services/api.service.ts @@ -32,15 +32,15 @@ export abstract class APIService { return this.axiosInstance.get(url, params); } - post(url: string, data: any, config = {}) { + post(url: string, data = {}, config = {}) { return this.axiosInstance.post(url, data, config); } - put(url: string, data: any, config = {}) { + put(url: string, data = {}, config = {}) { return this.axiosInstance.put(url, data, config); } - patch(url: string, data: any, config = {}) { + patch(url: string, data = {}, config = {}) { return this.axiosInstance.patch(url, data, config); } diff --git a/space/core/services/file-upload.service.ts b/space/core/services/file-upload.service.ts new file mode 100644 index 000000000..09e95f3c0 --- /dev/null +++ b/space/core/services/file-upload.service.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +// services +import { APIService } from "@/services/api.service"; + +export class FileUploadService extends APIService { + private cancelSource: any; + + constructor() { + super(""); + } + + async uploadFile(url: string, data: FormData): Promise { + this.cancelSource = axios.CancelToken.source(); + return this.post(url, data, { + headers: { + "Content-Type": "multipart/form-data", + }, + cancelToken: this.cancelSource.token, + withCredentials: false, + }) + .then((response) => response?.data) + .catch((error) => { + if (axios.isCancel(error)) { + console.log(error.message); + } else { + throw error?.response?.data; + } + }); + } + + cancelUpload() { + this.cancelSource.cancel("Upload canceled"); + } +} diff --git a/space/core/services/file.service.ts b/space/core/services/file.service.ts index 9fe06cd36..168738804 100644 --- a/space/core/services/file.service.ts +++ b/space/core/services/file.service.ts @@ -1,106 +1,100 @@ -import axios from "axios"; +// plane types +import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; // services import { APIService } from "@/services/api.service"; +import { FileUploadService } from "@/services/file-upload.service"; -class FileService extends APIService { +export class FileService extends APIService { private cancelSource: any; + fileUploadService: FileUploadService; constructor() { super(API_BASE_URL); - this.uploadFile = this.uploadFile.bind(this); - this.deleteImage = this.deleteImage.bind(this); - this.restoreImage = this.restoreImage.bind(this); this.cancelUpload = this.cancelUpload.bind(this); + // services + this.fileUploadService = new FileUploadService(); } - async uploadFile(workspaceSlug: string, file: FormData): Promise { - this.cancelSource = axios.CancelToken.source(); - return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, { - headers: { - "Content-Type": "multipart/form-data", - }, - cancelToken: this.cancelSource.token, - }) + private async updateAssetUploadStatus(anchor: string, assetId: string): Promise { + return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`) .then((response) => response?.data) .catch((error) => { - if (axios.isCancel(error)) { - console.log(error.message); - } else { - console.log(error); - throw error?.response?.data; - } + throw error?.response?.data; + }); + } + + async updateBulkAssetsUploadStatus( + anchor: string, + entityId: string, + data: { + asset_ids: string[]; + } + ): Promise { + return this.post(`/api/public/assets/v2/anchor/${anchor}/${entityId}/bulk/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/public/assets/v2/anchor/${anchor}/`, { + ...data, + ...fileMetaData, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload); + await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteNewAsset(assetPath: string): Promise { + return this.delete(assetPath) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`) + .then((response) => response?.status) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreNewAsset(workspaceSlug: string, src: string): Promise { + // remove the last slash and get the asset id + const assetId = getAssetIdFromUrl(src); + return this.post(`/api/public/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; }); } cancelUpload() { this.cancelSource.cancel("Upload cancelled"); } - - getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { - return async (file: File) => { - const formData = new FormData(); - formData.append("asset", file); - formData.append("attributes", JSON.stringify({})); - - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; - }; - } - - getDeleteImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.deleteImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - getRestoreImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.restoreImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - extractAssetIdFromUrl(src: string, workspaceId: string): string { - const indexWhereAssetIdStarts = src.indexOf(workspaceId) + workspaceId.length + 1; - if (indexWhereAssetIdStarts === -1) { - throw new Error("Workspace ID not found in source string"); - } - const assetUrl = src.substring(indexWhereAssetIdStarts); - return assetUrl; - } - - async deleteImage(assetUrlWithWorkspaceId: string): Promise { - return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } - - async restoreImage(assetUrlWithWorkspaceId: string): Promise { - return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, { - "Content-Type": "application/json", - }) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } } - -const fileService = new FileService(); - -export default fileService; diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index 2f19b4f08..b5ecb8077 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; // types -import { TIssuesResponse, IIssue } from "@/types/issue"; +import { Comment, TIssuesResponse, IIssue } from "@/types/issue"; class IssueService extends APIService { constructor() { @@ -83,7 +83,7 @@ class IssueService extends APIService { }); } - async createIssueComment(anchor: string, issueID: string, data: any): Promise { + async createIssueComment(anchor: string, issueID: string, data: any): Promise { return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) .then((response) => response?.data) .catch((error) => { diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 8b4710b17..9abed2966 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -3,12 +3,16 @@ import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; +// plane types +import { TFileSignedURLResponse } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // services +import { FileService } from "@/services/file.service"; import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { IIssue, IPeekMode, IVote } from "@/types/issue"; +import { Comment, IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; @@ -28,9 +32,11 @@ export interface IIssueDetailStore { // issue actions fetchIssueDetails: (anchor: string, issueID: string) => void; // comment actions - addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; + uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise; + uploadIssueAsset: (file: File, anchor: string, commentID?: string) => Promise; addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; // reaction actions @@ -54,6 +60,7 @@ export class IssueDetailStore implements IIssueDetailStore { rootStore: CoreRootStore; // services issueService: IssueService; + fileService: FileService; constructor(_rootStore: CoreRootStore) { makeObservable(this, { @@ -72,6 +79,8 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment: action, updateIssueComment: action, deleteIssueComment: action, + uploadCommentAsset: action, + uploadIssueAsset: action, addCommentReaction: action, removeCommentReaction: action, // reaction actions @@ -83,6 +92,7 @@ export class IssueDetailStore implements IIssueDetailStore { }); this.rootStore = _rootStore; this.issueService = new IssueService(); + this.fileService = new FileService(); } setPeekId = (issueID: string | null) => { @@ -220,6 +230,40 @@ export class IssueDetailStore implements IIssueDetailStore { } }; + uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + + uploadIssueAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { const newReaction = { id: uuidv4(), diff --git a/space/core/store/user.store.ts b/space/core/store/user.store.ts index 33b2cbe60..6616b10b0 100644 --- a/space/core/store/user.store.ts +++ b/space/core/store/user.store.ts @@ -79,7 +79,7 @@ export class UserStore implements IUserStore { first_name: this.data?.first_name, last_name: this.data?.last_name, display_name: this.data?.display_name, - avatar: this.data?.avatar || undefined, + avatar_url: this.data?.avatar_url || undefined, is_bot: false, }; } diff --git a/space/core/types/intake.d.ts b/space/core/types/intake.d.ts new file mode 100644 index 000000000..9cf2934f6 --- /dev/null +++ b/space/core/types/intake.d.ts @@ -0,0 +1,6 @@ +export type TIntakeIssueForm = { + name: string; + email: string; + username: string; + description_html: string; +}; diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index 79c6257d5..3041a188d 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -139,7 +139,7 @@ export interface IIssueReaction { } export interface ActorDetail { - avatar?: string; + avatar_url?: string; display_name?: string; first_name?: string; is_bot?: boolean; diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts new file mode 100644 index 000000000..b037055af --- /dev/null +++ b/space/helpers/editor.helper.ts @@ -0,0 +1,82 @@ +// plane editor +import { TFileHandler } from "@plane/editor"; +// constants +import { MAX_FILE_SIZE } from "@/constants/common"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); + +/** + * @description generate the file source using assetId + * @param {string} anchor + */ +export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { + const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); + return url; +}; + +type TArgs = { + anchor: string; + uploadFile: (file: File) => Promise; + workspaceId: string; +}; + +/** + * @description this function returns the file handler required by the editors + * @param {TArgs} args + */ +export const getEditorFileHandlers = (args: TArgs): TFileHandler => { + const { anchor, uploadFile, workspaceId } = args; + + return { + getAssetSrc: async (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.deleteOldEditorAsset(workspaceId, src); + } else { + await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + } + }, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.restoreOldEditorAsset(workspaceId, src); + } else { + await fileService.restoreNewAsset(anchor, src); + } + }, + cancel: fileService.cancelUpload, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, + }; +}; + +/** + * @description this function returns the file handler required by the read-only editors + */ +export const getReadOnlyEditorFileHandlers = ( + args: Pick +): { getAssetSrc: TFileHandler["getAssetSrc"] } => { + const { anchor } = args; + + return { + getAssetSrc: async (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + }; +}; diff --git a/space/helpers/file.helper.ts b/space/helpers/file.helper.ts new file mode 100644 index 000000000..b149ebc7c --- /dev/null +++ b/space/helpers/file.helper.ts @@ -0,0 +1,51 @@ +// plane types +import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; + +/** + * @description from the provided signed URL response, generate a payload to be used to upload the file + * @param {TFileSignedURLResponse} signedURLResponse + * @param {File} file + * @returns {FormData} file upload request payload + */ +export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { + const formData = new FormData(); + Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); + formData.append("file", file); + return formData; +}; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; + +/** + * @description returns the necessary file meta data to upload a file + * @param {File} file + * @returns {TFileMetaDataLite} payload with file info + */ +export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ + name: file.name, + size: file.size, + type: file.type, +}); + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl; +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 5c704c44c..dc838596a 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -78,3 +78,25 @@ export const isCommentEmpty = (comment: string | undefined): boolean => { export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/space/package.json b/space/package.json index 99422406c..7d5f375dd 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.23.1", + "version": "0.24.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -28,7 +28,6 @@ "date-fns": "^3.6.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", - "js-cookie": "^3.0.1", "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "^0.378.0", @@ -52,12 +51,11 @@ "@plane/eslint-config": "*", "@plane/typescript-config": "*", "@types/dompurify": "^3.0.5", - "@types/js-cookie": "^3.0.3", "@types/lodash": "^4.17.1", "@types/node": "18.14.1", "@types/nprogress": "^0.2.0", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.2.18", "@types/uuid": "^9.0.1", "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.48.2", diff --git a/space/public/instance/intake-sent-dark.png b/space/public/instance/intake-sent-dark.png new file mode 100644 index 000000000..70a62730e Binary files /dev/null and b/space/public/instance/intake-sent-dark.png differ diff --git a/space/public/instance/intake-sent-light.png b/space/public/instance/intake-sent-light.png new file mode 100644 index 000000000..9425c0723 Binary files /dev/null and b/space/public/instance/intake-sent-light.png differ diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 72dae40b1..4edf41bbd 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -16,7 +16,7 @@ export const WorkspaceActiveCycleHeader = observer(() => ( type="text" link={ } /> } diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 000000000..f77e61c31 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { PenSquare } from "lucide-react"; +// ui +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { BreadcrumbLink, CountChip } from "@/components/common"; +import { CreateUpdateIssueModal } from "@/components/issues"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store"; +// plane-web +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + +export const WorkspaceDraftHeader = observer(() => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { paginationInfo } = useWorkspaceDraftIssues(); + const { joinedProjectIds } = useProject(); + // check if user is authorized to create draft issue + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + return ( + <> + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ +
+ + } />} + /> + + {paginationInfo?.total_count && paginationInfo?.total_count > 0 ? ( + + ) : ( + <> + )} +
+
+ + + {joinedProjectIds && joinedProjectIds.length > 0 && ( + + )} + +
+ + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 000000000..a5a647bfd --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceDraftHeader } from "./header"; + +export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 000000000..f94fc872a --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft"; + +const WorkspaceDraftPage = () => { + // router + const { workspaceSlug: routeWorkspaceSlug } = useParams(); + const pageTitle = "Workspace Draft"; + + // derived values + const workspaceSlug = (routeWorkspaceSlug as string) || undefined; + + if (!workspaceSlug) return null; + return ( + <> + +
+ +
+ + ); +}; + +export default WorkspaceDraftPage; diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx index 1c2036efd..4ea0c8e42 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; @@ -62,6 +62,11 @@ const WorkspaceDashboardPage = observer(() => { workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null ); + const embedRemoveCurrentNotification = useCallback( + () => setCurrentSelectedNotificationId(undefined), + [setCurrentSelectedNotificationId] + ); + // clearing up the selected notifications when unmounting the page useEffect( () => () => { @@ -95,15 +100,12 @@ const WorkspaceDashboardPage = observer(() => { projectId={project_id} inboxIssueId={issue_id} isNotificationEmbed - embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)} + embedRemoveCurrentNotification={embedRemoveCurrentNotification} /> )} ) : ( - setCurrentSelectedNotificationId(undefined)} - /> + )} )} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 6da6d7749..f95611fbc 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -46,7 +46,6 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => { type="text" link={ { ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` : null, workspaceSlug && projectId && archivedIssueId - ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString(), "ARCHIVED") + ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) : null ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index bb0c63d70..276f9ff25 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -13,9 +13,9 @@ import { ISSUE_DETAILS } from "@/constants/fetch-keys"; // hooks import { useProject } from "@/hooks/store"; // services -import { IssueArchiveService } from "@/services/issue"; +import { IssueService } from "@/services/issue"; -const issueArchiveService = new IssueArchiveService(); +const issueService = new IssueService(); export const ProjectArchivedIssueDetailsHeader = observer(() => { // router @@ -24,14 +24,9 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => { const { currentProjectDetails, loader } = useProject(); const { data: issueDetails } = useSWR( - workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null, + workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId.toString()) : null, workspaceSlug && projectId && archivedIssueId - ? () => - issueArchiveService.retrieveArchivedIssue( - workspaceSlug as string, - projectId as string, - archivedIssueId as string - ) + ? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString()) : null ); @@ -43,7 +38,6 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => { type="text" link={ { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 27e33e2c2..aa81ae581 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => { const { getCycleById } = useCycle(); const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index b2e157ee7..ca3189a1f 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -38,7 +38,6 @@ export const CyclesListHeader: FC = observer(() => { link={ diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index 49816bad7..c1e866d03 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -99,7 +99,6 @@ export const ProjectDraftIssueHeader: FC = observer(() => { type="text" link={ { type="text" link={ } /> - {children} + {children} ); } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx index e61b9fefd..1b1cffcc6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -54,7 +54,6 @@ export const ProjectIssuesHeader = observer(() => { type="text" link={ { const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; const [analyticsModal, setAnalyticsModal] = useState(false); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 59dcae31b..38c2f4453 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -171,7 +171,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index f67df642c..0edfa2b5a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => { const { getModuleById } = useModule(); const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; const { workspaceSlug, projectId, moduleId } = useParams() as { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx index 5190dcced..6da84e56a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -39,7 +39,6 @@ export const ModulesListHeader: React.FC = observer(() => { type="text" link={ { { type="text" link={ { type="text" link={ { type="text" link={ { type="text" link={ { const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Views` : undefined; - if (!workspaceSlug || !projectId) return <>; - - // No access to - if (currentProjectDetails?.issue_views_view === false) - return ( -
- -
- ); const handleRemoveFilter = useCallback( (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { let newValues = filters.filters?.[key]; @@ -59,6 +47,19 @@ const ProjectViewsPage = observer(() => { const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + if (!workspaceSlug || !projectId) return <>; + + // No access to + if (currentProjectDetails?.issue_views_view === false) + return ( +
+ +
+ ); + return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index f4ee6e91f..18451d4bb 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -1,4 +1,5 @@ import { FC, useEffect, useRef } from "react"; +import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; // plane helpers import { useOutsideClickDetector } from "@plane/helpers"; @@ -16,8 +17,9 @@ import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/f import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme, useUserPermissions } from "@/hooks/store"; -// plane web components +import { useFavorite } from "@/hooks/store/use-favorite"; import useSize from "@/hooks/use-window-size"; +// plane web components import { SidebarAppSwitcher } from "@/plane-web/components/sidebar"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; @@ -25,6 +27,7 @@ export const AppSidebar: FC = observer(() => { // store hooks const { allowPermissions } = useUserPermissions(); const { toggleSidebar, sidebarCollapsed } = useAppTheme(); + const { groupedFavorites } = useFavorite(); const windowSize = useSize(); // refs const ref = useRef(null); @@ -48,6 +51,8 @@ export const AppSidebar: FC = observer(() => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [windowSize]); + const isFavoriteEmpty = isEmpty(groupedFavorites); + return (
{ "opacity-0": !sidebarCollapsed, })} /> - {canPerformWorkspaceMemberActions && } + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && }
diff --git a/web/app/create-workspace/page.tsx b/web/app/create-workspace/page.tsx index 1a5625ad6..36bc8978a 100644 --- a/web/app/create-workspace/page.tsx +++ b/web/app/create-workspace/page.tsx @@ -7,15 +7,19 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import { IWorkspace } from "@plane/types"; // components +import { Button, getButtonStyling } from "@plane/ui"; import { CreateWorkspaceForm } from "@/components/workspace"; // hooks import { useUser, useUserProfile } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; // images import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; const CreateWorkspacePage = observer(() => { // router @@ -31,6 +35,8 @@ const CreateWorkspacePage = observer(() => { }); // hooks const { resolvedTheme } = useTheme(); + // derived values + const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); const onSubmit = async (workspace: IWorkspace) => { await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); @@ -56,16 +62,38 @@ const CreateWorkspacePage = observer(() => {
-
-

Create your workspace

-
- + {isWorkspaceCreationDisabled ? ( +
+ +
Only your instance admin can create workspaces
+

+ If you know your instance admin's email address,
click the button below to get in touch with + them. +

+
+ + + Request instance admin + +
-
+ ) : ( +
+

Create your workspace

+
+ +
+
+ )}
diff --git a/web/app/error.tsx b/web/app/error.tsx index da5d3bf20..b176799b4 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -39,7 +39,7 @@ export default function CustomErrorComponent() {
-
+

Yikes! That doesn{"'"}t look good.

That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 428be8272..1dd9702a3 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -6,23 +6,32 @@ import { Controller, useForm } from "react-hook-form"; import { ChevronDown, CircleUserRound } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import type { IUser } from "@plane/types"; -import { Button, CustomSelect, CustomSearchSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; +import { + Button, + CustomSelect, + CustomSearchSelect, + Input, + TOAST_TYPE, + setPromiseToast, + setToast, + Tooltip, +} from "@plane/ui"; // components import { DeactivateAccountModal } from "@/components/account"; import { LogoSpinner } from "@/components/common"; import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; import { ProfileSettingContentWrapper } from "@/components/profile"; // constants -import { TIME_ZONES } from "@/constants/timezones"; +import { TIME_ZONES, TTimezone } from "@/constants/timezones"; import { USER_ROLES } from "@/constants/workspace"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; const defaultValues: Partial = { - avatar: "", - cover_image: "", + avatar_url: "", + cover_image_url: "", first_name: "", last_name: "", display_name: "", @@ -31,12 +40,9 @@ const defaultValues: Partial = { user_timezone: "Asia/Kolkata", }; -const fileService = new FileService(); - const ProfileSettingsPage = observer(() => { // states const [isLoading, setIsLoading] = useState(false); - const [isRemoving, setIsRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); // form info @@ -48,6 +54,9 @@ const ProfileSettingsPage = observer(() => { setValue, formState: { errors }, } = useForm({ defaultValues }); + // derived values + const userAvatar = watch("avatar_url"); + const userCover = watch("cover_image_url"); // store hooks const { data: currentUser, updateCurrentUser } = useUser(); @@ -60,12 +69,16 @@ const ProfileSettingsPage = observer(() => { const payload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, - cover_image: formData.cover_image, + avatar_url: formData.avatar_url, role: formData.role, display_name: formData?.display_name, user_timezone: formData.user_timezone, }; + // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset + if (formData.cover_image_url?.startsWith("http")) { + payload.cover_image = formData.cover_image_url; + payload.cover_image_asset = null; + } const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); setPromiseToast(updateCurrentUserDetail, { @@ -81,41 +94,46 @@ const ProfileSettingsPage = observer(() => { }); }; - const handleDelete = (url: string | null | undefined, updateUser: boolean = false) => { + const handleDelete = async (url: string | null | undefined) => { if (!url) return; - setIsRemoving(true); + await updateCurrentUser({ + avatar_url: "", + }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Profile picture deleted successfully.", + }); + setValue("avatar_url", ""); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "There was some error in deleting your profile picture. Please try again.", + }); + }) + .finally(() => { + setIsImageUploadModalOpen(false); + }); + }; - fileService.deleteUserFile(url).then(() => { - if (updateUser) - updateCurrentUser({ avatar: "" }) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Profile picture deleted successfully.", - }); - setIsRemoving(false); - setValue("avatar", ""); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "There was some error in deleting your profile picture. Please try again.", - }); - }) - .finally(() => { - setIsRemoving(false); - setIsImageUploadModalOpen(false); - }); - }); + const getTimeZoneLabel = (timezone: TTimezone | undefined) => { + if (!timezone) return undefined; + return ( +

+ {timezone.gmtOffset} + {timezone.name} +
+ ); }; const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ value: timeZone.value, - query: timeZone.label + " " + timeZone.value, - content: timeZone.label, + query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, + content: getTimeZoneLabel(timeZone), })); if (!currentUser) @@ -131,13 +149,12 @@ const ProfileSettingsPage = observer(() => { ( setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(currentUser?.avatar, true)} + handleRemove={async () => await handleDelete(currentUser?.avatar_url)} onSuccess={(url) => { onChange(url); handleSubmit(onSubmit)(); @@ -149,25 +166,25 @@ const ProfileSettingsPage = observer(() => { /> setDeactivateAccountModal(false)} />
-
+
{currentUser?.first_name -
+
-
( { />
- -
+
-
+
{`${watch("first_name")} ${watch("last_name")}`}
- {watch("email")} + {watch("email")}
- - {/* - - - Activity Overview - - */}
- -
-
-

- First name* -

- ( - +
+
+
+

+ First name* +

+ ( + + )} + /> + {errors.first_name && {errors.first_name.message}} +
+
+

Last name

+ ( + + )} + /> +
+
+

+ Display name* +

+ { + if (value.trim().length < 1) return "Display name can't be empty."; + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + if (value.replace(/\s/g, "").length < 1) + return "Display name must be at least 1 character long."; + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors?.display_name && ( + {errors?.display_name?.message} )} - /> - {errors.first_name && Please enter first name} +
+
+

+ Email* +

+ ( + + )} + /> +
+
+

+ Role* +

+ ( + + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && Please select a role} +
- -
-

Last name

- - ( - - )} - /> -
- -
-

- Email* -

- ( - +
+
+
+

+ Timezone* +

+ ( + t.value === value)) ?? value) + : "Select a timezone" + } + options={timeZoneOptions} + onChange={onChange} + buttonClassName={errors.user_timezone ? "border-red-500" : ""} + className="rounded-md border-[0.5px] !border-custom-border-200" + optionsClassName="w-72" + input + /> + )} + /> + {errors.user_timezone && {errors.user_timezone.message}} +
+ +
+

Language

+ {}} + className="rounded-md bg-custom-background-90" + input disabled /> - )} - /> +
+
- -
-

- Role* -

- ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
- -
-

- Display name* -

- { - if (value.trim().length < 1) return "Display name can't be empty."; - - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 characters long."; - - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors?.display_name && {errors?.display_name?.message}} -
- -
-

- Timezone* -

- - ( - t.value === value)?.label ?? value) : "Select a timezone"} - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} - className="rounded-md border-[0.5px] !border-custom-border-200" - input - /> - )} - /> - {errors.role && Please select a time zone} -
- -
+
@@ -398,11 +420,11 @@ const ProfileSettingsPage = observer(() => {
- + {({ open }) => ( <> - Deactivate account + Deactivate account { ); }); -// ProfileSettingsPage.getLayout = function getLayout(page: ReactElement) { -// return {page}; -// }; - export default ProfileSettingsPage; diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index adb06863d..aec3e24dc 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -16,6 +16,7 @@ import { SidebarNavItem } from "@/components/sidebar"; import { PROFILE_ACTION_LINKS } from "@/constants/profile"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -180,17 +181,17 @@ export const ProfileLayoutSidebar = observer(() => { > - {workspace?.logo && workspace.logo !== "" ? ( + {workspace?.logo_url && workspace.logo_url !== "" ? ( Workspace Logo ) : ( - workspace?.name?.charAt(0) ?? "..." + (workspace?.name?.charAt(0) ?? "...") )} {!sidebarCollapsed && ( diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/web/ce/components/cycles/analytics-sidebar/base.tsx new file mode 100644 index 000000000..87f07e387 --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -0,0 +1,95 @@ +"use client"; +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +// plane ui +import { TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { EstimateTypeDropdown, validateCycleSnapshot } from "@/components/cycles"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useCycle } from "@/hooks/store"; + +type ProgressChartProps = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; +export const SidebarChart: FC = observer((props) => { + const { workspaceSlug, projectId, cycleId } = props; + + // hooks + const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } = + useCycle(); + + // derived data + const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); + const cycleStartDate = getDate(cycleDetails?.start_date); + const cycleEndDate = getDate(cycleDetails?.end_date); + const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; + const totalIssues = cycleDetails?.total_issues || 0; + const estimateType = getEstimateTypeByCycleId(cycleId); + + const chartDistributionData = + estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; + + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + if (!workspaceSlug || !projectId || !cycleId) return null; + + const isArchived = !!cycleDetails?.archived_at; + + // handlers + const onChange = async (value: TCycleEstimateType) => { + setEstimateType(cycleId, value); + if (!workspaceSlug || !projectId || !cycleId) return; + try { + if (isArchived) { + await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); + } else { + await fetchCycleDetails(workspaceSlug, projectId, cycleId); + } + } catch (err) { + console.error(err); + setEstimateType(cycleId, estimateType); + } + }; + return ( + <> +
+ +
+
+
+
+
+ + Ideal +
+
+ + Current +
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + + + ) : ( + + + + )} +
+
+ + ); +}); diff --git a/web/ce/components/cycles/analytics-sidebar/index.ts b/web/ce/components/cycles/analytics-sidebar/index.ts index 3ba38c61b..1efe34c51 100644 --- a/web/ce/components/cycles/analytics-sidebar/index.ts +++ b/web/ce/components/cycles/analytics-sidebar/index.ts @@ -1 +1 @@ -export * from "./sidebar-chart"; +export * from "./root"; diff --git a/web/ce/components/cycles/analytics-sidebar/root.tsx b/web/ce/components/cycles/analytics-sidebar/root.tsx new file mode 100644 index 000000000..d18f9168d --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -0,0 +1,12 @@ +"use client"; +import React, { FC } from "react"; +// components +import { SidebarChart } from "./base"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +export const SidebarChartRoot: FC = (props) => ; diff --git a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx b/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx deleted file mode 100644 index e5b69ef24..000000000 --- a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Fragment } from "react"; -import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types"; -import { Loader } from "@plane/ui"; -import ProgressChart from "@/components/core/sidebar/progress-chart"; - -type ProgressChartProps = { - chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined; - cycleStartDate: Date | undefined; - cycleEndDate: Date | undefined; - totalEstimatePoints: number; - totalIssues: number; - plotType: string; -}; -export const SidebarBaseChart = (props: ProgressChartProps) => { - const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props; - const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; - - return ( -
-
-
- - Ideal -
-
- - Current -
-
- {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( - - {plotType === "points" ? ( - - ) : ( - - )} - - ) : ( - - - - )} -
- ); -}; diff --git a/web/ce/components/de-dupe/de-dupe-button.tsx b/web/ce/components/de-dupe/de-dupe-button.tsx new file mode 100644 index 000000000..eaa4e3b7c --- /dev/null +++ b/web/ce/components/de-dupe/de-dupe-button.tsx @@ -0,0 +1,15 @@ +"use client"; +import React, { FC } from "react"; +// local components + +type TDeDupeButtonRoot = { + workspaceSlug: string; + isDuplicateModalOpen: boolean; + handleOnClick: () => void; + label: string; +}; + +export const DeDupeButtonRoot: FC = (props) => { + const { workspaceSlug, isDuplicateModalOpen, label, handleOnClick } = props; + return <>; +}; diff --git a/web/ce/components/de-dupe/duplicate-modal/index.ts b/web/ce/components/de-dupe/duplicate-modal/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-modal/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/de-dupe/duplicate-modal/root.tsx b/web/ce/components/de-dupe/duplicate-modal/root.tsx new file mode 100644 index 000000000..42284c6ed --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -0,0 +1,16 @@ +"use-client"; + +import { FC } from "react"; +// types +import { TDeDupeIssue } from "@plane/types"; + +type TDuplicateModalRootProps = { + workspaceSlug: string; + issues: TDeDupeIssue[]; + handleDuplicateIssueModal: (value: boolean) => void; +}; + +export const DuplicateModalRoot: FC = (props) => { + const { workspaceSlug, issues, handleDuplicateIssueModal } = props; + return <>; +}; diff --git a/web/ce/components/de-dupe/duplicate-popover/index.ts b/web/ce/components/de-dupe/duplicate-popover/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-popover/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/de-dupe/duplicate-popover/root.tsx b/web/ce/components/de-dupe/duplicate-popover/root.tsx new file mode 100644 index 000000000..ad1c2e5c3 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +// types +import { TDeDupeIssue } from "@plane/types"; +import { TIssueOperations } from "@/components/issues"; + +type TDeDupeIssuePopoverRootProps = { + workspaceSlug: string; + projectId: string; + rootIssueId: string; + issues: TDeDupeIssue[]; + issueOperations: TIssueOperations; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssuePopoverRoot: FC = observer((props) => { + const { + workspaceSlug, + projectId, + rootIssueId, + issues, + issueOperations, + disabled = false, + renderDeDupeActionModals = true, + isIntakeIssue = false, + } = props; + return <>; +}); diff --git a/web/ce/components/de-dupe/index.ts b/web/ce/components/de-dupe/index.ts new file mode 100644 index 000000000..91856db18 --- /dev/null +++ b/web/ce/components/de-dupe/index.ts @@ -0,0 +1,4 @@ +export * from "./de-dupe-button"; +export * from "./duplicate-modal"; +export * from "./duplicate-popover"; +export * from "./issue-block"; diff --git a/web/ce/components/de-dupe/issue-block/button-label.tsx b/web/ce/components/de-dupe/issue-block/button-label.tsx new file mode 100644 index 000000000..303b0cec6 --- /dev/null +++ b/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { FC } from "react"; + +type TDeDupeIssueButtonLabelProps = { + isOpen: boolean; + buttonLabel: string; +}; + +export const DeDupeIssueButtonLabel: FC = (props) => { + const { isOpen, buttonLabel } = props; + return <>; +}; diff --git a/web/ce/components/de-dupe/issue-block/index.ts b/web/ce/components/de-dupe/issue-block/index.ts new file mode 100644 index 000000000..f50893b65 --- /dev/null +++ b/web/ce/components/de-dupe/issue-block/index.ts @@ -0,0 +1 @@ +export * from "./button-label"; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts b/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts new file mode 100644 index 000000000..c2f4f8aec --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts @@ -0,0 +1,2 @@ +export * from "./left-draggable"; +export * from "./right-draggable"; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx new file mode 100644 index 000000000..ccb3780c5 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -0,0 +1,9 @@ +import { RefObject } from "react"; +import { IGanttBlock } from "@/components/gantt-chart"; + +type LeftDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; + +export const LeftDependencyDraggable = (props: LeftDependencyDraggableProps) => <>; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx new file mode 100644 index 000000000..3d5ac24e0 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -0,0 +1,8 @@ +import { RefObject } from "react"; +import { IGanttBlock } from "@/components/gantt-chart"; + +type RightDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; +export const RightDependencyDraggable = (props: RightDependencyDraggableProps) => <>; diff --git a/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx new file mode 100644 index 000000000..f049875f1 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -0,0 +1 @@ +export const TimelineDependencyPaths = () => <>; diff --git a/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx b/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx new file mode 100644 index 000000000..3b4aa350d --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx @@ -0,0 +1 @@ +export const TimelineDraggablePath = () => <>; diff --git a/web/ce/components/gantt-chart/dependency/index.ts b/web/ce/components/gantt-chart/dependency/index.ts new file mode 100644 index 000000000..91d0018db --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/index.ts @@ -0,0 +1,3 @@ +export * from "./blockDraggables"; +export * from "./dependency-paths"; +export * from "./draggable-dependency-path"; diff --git a/web/ce/components/gantt-chart/index.ts b/web/ce/components/gantt-chart/index.ts new file mode 100644 index 000000000..d08e0f7d6 --- /dev/null +++ b/web/ce/components/gantt-chart/index.ts @@ -0,0 +1 @@ +export * from "./dependency"; diff --git a/web/ce/components/global/index.ts b/web/ce/components/global/index.ts index 2d8930e19..c87c8ae02 100644 --- a/web/ce/components/global/index.ts +++ b/web/ce/components/global/index.ts @@ -1,3 +1,2 @@ export * from "./version-number"; -export * from "./product-updates"; -export * from "./product-updates-modal"; +export * from "./product-updates-header"; diff --git a/web/ce/components/global/product-updates-header.tsx b/web/ce/components/global/product-updates-header.tsx new file mode 100644 index 000000000..a5965bb2d --- /dev/null +++ b/web/ce/components/global/product-updates-header.tsx @@ -0,0 +1,26 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +// helpers +import { cn } from "@/helpers/common.helper"; +// assets +import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; +// package.json +import packageJson from "package.json"; + +export const ProductUpdatesHeader = observer(() => ( +
+
+
What's new
+
+ Version: v{packageJson.version} +
+
+
+ +
+
+)); diff --git a/web/ce/components/global/product-updates-modal.tsx b/web/ce/components/global/product-updates-modal.tsx deleted file mode 100644 index da2795835..000000000 --- a/web/ce/components/global/product-updates-modal.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; - -export type ProductUpdatesModalProps = { - isOpen: boolean; - handleClose: () => void; -}; - -export const ProductUpdatesModal: FC = observer(() => <>); diff --git a/web/ce/components/global/product-updates.tsx b/web/ce/components/global/product-updates.tsx deleted file mode 100644 index 700883574..000000000 --- a/web/ce/components/global/product-updates.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -// ui -import { CustomMenu } from "@plane/ui"; - -export type ProductUpdatesProps = { - setIsChangeLogOpen: (isOpen: boolean) => void; -}; - -export const ProductUpdates: FC = observer(() => ( - - - What's new - - -)); diff --git a/web/ce/components/instance/index.ts b/web/ce/components/instance/index.ts new file mode 100644 index 000000000..960f954e7 --- /dev/null +++ b/web/ce/components/instance/index.ts @@ -0,0 +1 @@ +export * from "./maintenance-message"; diff --git a/web/ce/components/instance/maintenance-message.tsx b/web/ce/components/instance/maintenance-message.tsx new file mode 100644 index 000000000..a55d7d149 --- /dev/null +++ b/web/ce/components/instance/maintenance-message.tsx @@ -0,0 +1,6 @@ +export const MaintenanceMessage = () => ( +

+ Plane didn't start up. This could be because one or more Plane services failed to start.
Choose View + Logs from setup.sh and Docker logs to be sure. +

+); diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index c461e88fa..fbd943984 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,3 +1,4 @@ +import { FC } from "react"; import { observer } from "mobx-react"; // types import { IIssueDisplayProperties } from "@plane/types"; @@ -28,6 +29,13 @@ type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & { export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; +type TIssueTypeIdentifier = { + issueTypeId: string; + size?: "xs" | "sm" | "md" | "lg"; +}; + +export const IssueTypeIdentifier: FC = observer((props) => <>); + type TIdentifierTextProps = { identifier: string; enableClickToCopyIdentifier?: boolean; diff --git a/web/ce/components/issues/issue-modal/additional-properties.tsx b/web/ce/components/issues/issue-modal/additional-properties.tsx index 228ab51e8..99ddc8830 100644 --- a/web/ce/components/issues/issue-modal/additional-properties.tsx +++ b/web/ce/components/issues/issue-modal/additional-properties.tsx @@ -3,6 +3,7 @@ type TIssueAdditionalPropertiesProps = { issueTypeId: string | null; projectId: string; workspaceSlug: string; + isDraft?: boolean; }; export const IssueAdditionalProperties: React.FC = () => <>; diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index d3440ea47..211155d37 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -11,11 +11,13 @@ type Props = { handleInsertText: (insertOnNextLine: boolean) => void; handleRegenerate: () => Promise; isRegenerating: boolean; + projectId: string; response: string | undefined; + workspaceSlug: string; }; export const AskPiMenu: React.FC = (props) => { - const { handleInsertText, handleRegenerate, isRegenerating, response } = props; + const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props; // states const [query, setQuery] = useState(""); @@ -39,6 +41,8 @@ export const AskPiMenu: React.FC = (props) => { initialValue={response} containerClassName="!p-0 border-none" editorClassName="!pl-0" + workspaceSlug={workspaceSlug} + projectId={projectId} />
- +
+
+ +

+ Embed and access issues in pages seamlessly, upgrade to Plane Pro now. +

+ + Upgrade +
); diff --git a/web/ce/components/projects/create/root.tsx b/web/ce/components/projects/create/root.tsx index 5d6741418..c7dabd20e 100644 --- a/web/ce/components/projects/create/root.tsx +++ b/web/ce/components/projects/create/root.tsx @@ -16,19 +16,21 @@ import { getRandomEmoji } from "@/helpers/emoji.helper"; // hooks import { useEventTracker, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web types import { TProject } from "@/plane-web/types/projects"; import ProjectAttributes from "./attributes"; -type Props = { +export type TCreateProjectFormProps = { setToFavorite?: boolean; workspaceSlug: string; onClose: () => void; handleNextStep: (projectId: string) => void; data?: Partial; + updateCoverImageStatus: (projectId: string, coverImage: string) => Promise; }; const defaultValues: Partial = { - cover_image: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], + cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], description: "", logo_props: { in_use: "emoji", @@ -42,8 +44,8 @@ const defaultValues: Partial = { project_lead: null, }; -export const CreateProjectForm: FC = observer((props) => { - const { setToFavorite, workspaceSlug, onClose, handleNextStep } = props; +export const CreateProjectForm: FC = observer((props) => { + const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props; // store const { captureProjectEvent } = useEventTracker(); const { addProjectToFavorites, createProject } = useProject(); @@ -71,9 +73,18 @@ export const CreateProjectForm: FC = observer((props) => { const onSubmit = async (formData: Partial) => { // Upper case identifier formData.identifier = formData.identifier?.toUpperCase(); + const coverImage = formData.cover_image_url; + // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset + if (coverImage?.startsWith("http")) { + formData.cover_image = coverImage; + formData.cover_image_asset = null; + } return createProject(workspaceSlug.toString(), formData) - .then((res) => { + .then(async (res) => { + if (coverImage) { + await updateCoverImageStatus(res.id, coverImage); + } const newPayload = { ...res, state: "SUCCESS", diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx b/web/ce/components/projects/settings/intake/header.tsx similarity index 93% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx rename to web/ce/components/projects/settings/intake/header.tsx index a543eca0b..cffd1ba56 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -8,7 +8,7 @@ import { RefreshCcw } from "lucide-react"; import { Breadcrumbs, Button, Intake, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; -import { InboxIssueCreateEditModalRoot } from "@/components/inbox"; +import { InboxIssueCreateModalRoot } from "@/components/inbox"; // hooks import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; @@ -39,7 +39,6 @@ export const ProjectInboxHeader: FC = observer(() => { type="text" link={ { {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
- setCreateIssueModal(false)} - issue={undefined} /> - - - {updates && updates.length > 0 ? ( -
- {updates.map((item, index) => ( - -
- - {item.tag_name} - - {renderFormattedDate(item.published_at)} - {index === 0 && ( - - New - - )} -
- -
- ))} -
- ) : ( -
- -
- - - -
-
- - - -
-
- - - -
-
-
- )} -
- - -
-
- - - ); -}; diff --git a/web/core/components/core/content-wrapper.tsx b/web/core/components/core/content-wrapper.tsx index f95239967..eefc96b1e 100644 --- a/web/core/components/core/content-wrapper.tsx +++ b/web/core/components/core/content-wrapper.tsx @@ -1,13 +1,16 @@ "use client"; import { ReactNode } from "react"; +// helpers +import { cn } from "@/helpers/common.helper"; export interface ContentWrapperProps { + className?: string; children: ReactNode; } -export const ContentWrapper = ({ children }: ContentWrapperProps) => ( +export const ContentWrapper = ({ className, children }: ContentWrapperProps) => (
-
{children}
+
{children}
); diff --git a/web/core/components/core/image-picker-popover.tsx b/web/core/components/core/image-picker-popover.tsx index db9461991..c543f13f0 100644 --- a/web/core/components/core/image-picker-popover.tsx +++ b/web/core/components/core/image-picker-popover.tsx @@ -7,16 +7,18 @@ import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; import { Control, Controller } from "react-hook-form"; import useSWR from "swr"; -// headless ui import { Tab, Popover } from "@headlessui/react"; // plane helpers import { useOutsideClickDetector } from "@plane/helpers"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Button, Input, Loader } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useWorkspace, useInstance } from "@/hooks/store"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; // services import { FileService } from "@/services/file.service"; @@ -44,13 +46,14 @@ type Props = { disabled?: boolean; tabIndex?: number; isProfileCover?: boolean; + projectId?: string | null; }; // services const fileService = new FileService(); export const ImagePickerPopover: React.FC = observer((props) => { - const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props; + const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false, projectId } = props; // states const [image, setImage] = useState(null); const [isImageUploading, setIsImageUploading] = useState(false); @@ -63,9 +66,6 @@ export const ImagePickerPopover: React.FC = observer((props) => { const ref = useRef(null); // router params const { workspaceSlug } = useParams(); - // store hooks - const { config } = useInstance(); - const { currentWorkspace } = useWorkspace(); const { data: unsplashImages, error: unsplashError } = useSWR( `UNSPLASH_IMAGES_${searchParams}`, @@ -92,52 +92,42 @@ export const ImagePickerPopover: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, }); const handleSubmit = async () => { + if (!image) return; setIsImageUploading(true); - if (!image) return; - - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); - - const oldValue = value; - const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com"; - - const uploadCallback = (res: any) => { - const imageUrl = res.asset; - onChange(imageUrl); + const uploadCallback = (url: string) => { + onChange(url); setIsImageUploading(false); setImage(null); setIsOpen(false); }; if (isProfileCover) { - fileService - .uploadUserFile(formData) - .then((res) => { - uploadCallback(res); - if (isUnsplashImage) return; - if (oldValue && currentWorkspace) fileService.deleteUserFile(oldValue); - }) - .catch((err) => { - console.error(err); - }); + await fileService + .uploadUserAsset( + { + entity_identifier: "", + entity_type: EFileAssetType.USER_COVER, + }, + image + ) + .then((res) => uploadCallback(res.asset_url)); } else { if (!workspaceSlug) return; - fileService - .uploadFile(workspaceSlug.toString(), formData) - .then((res) => { - uploadCallback(res); - if (isUnsplashImage) return; - if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue); - }) - .catch((err) => { - console.error(err); - }); + await fileService + .uploadWorkspaceAsset( + workspaceSlug.toString(), + { + entity_identifier: projectId?.toString() ?? "", + entity_type: EFileAssetType.PROJECT_COVER, + }, + image + ) + .then((res) => uploadCallback(res.asset_url)); } }; @@ -332,7 +322,7 @@ export const ImagePickerPopover: React.FC = observer((props) => { diff --git a/web/core/components/core/modals/gpt-assistant-popover.tsx b/web/core/components/core/modals/gpt-assistant-popover.tsx index d093e0a1b..0056977ed 100644 --- a/web/core/components/core/modals/gpt-assistant-popover.tsx +++ b/web/core/components/core/modals/gpt-assistant-popover.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState, useRef, Fragment, Ref } from "react"; import { Placement } from "@popperjs/core"; -import { useParams } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; // services import { usePopper } from "react-popper"; import { AlertCircle } from "lucide-react"; @@ -23,6 +22,8 @@ type Props = { prompt?: string; button: JSX.Element; className?: string; + workspaceSlug: string; + projectId: string; }; type FormData = { @@ -33,7 +34,18 @@ type FormData = { const aiService = new AIService(); export const GptAssistantPopover: React.FC = (props) => { - const { isOpen, handleClose, onResponse, onError, placement, prompt, button, className = "" } = props; + const { + isOpen, + handleClose, + onResponse, + onError, + placement, + prompt, + button, + className = "", + workspaceSlug, + projectId, + } = props; // states const [response, setResponse] = useState(""); const [invalidResponse, setInvalidResponse] = useState(false); @@ -41,8 +53,6 @@ export const GptAssistantPopover: React.FC = (props) => { const [popperElement, setPopperElement] = useState(null); const editorRef = useRef(null); const responseRef = useRef(null); - // router - const { workspaceSlug } = useParams(); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", @@ -208,6 +218,8 @@ export const GptAssistantPopover: React.FC = (props) => { initialValue={prompt} containerClassName="-m-3" ref={editorRef} + workspaceSlug={workspaceSlug} + projectId={projectId} />
)} @@ -218,6 +230,8 @@ export const GptAssistantPopover: React.FC = (props) => { id="ai-assistant-response" initialValue={`

${response}

`} ref={responseRef} + workspaceSlug={workspaceSlug} + projectId={projectId} />
)} diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index 7e033053f..ad7a4daac 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -5,34 +5,33 @@ import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// hooks -import { useInstance } from "@/hooks/store"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // services import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type Props = { - handleDelete?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; }; -// services -const fileService = new FileService(); - export const UserImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); - // store hooks - const { config } = useInstance(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -41,7 +40,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); @@ -53,31 +52,46 @@ export const UserImageUploadModal: React.FC = observer((props) => { const handleSubmit = async () => { if (!image) return; - setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadUserAsset( + { + entity_identifier: "", + entity_type: EFileAssetType.USER_AVATAR, + }, + image + ); + onSuccess(asset_url); + setImage(null); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.toString() ?? "Something went wrong. Please try again.", + }); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - fileService - .uploadUserFile(formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value) fileService.deleteUserFile(value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldUserAsset(value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteUserAsset(assetId); + } + await handleRemove(); + } catch (error) { + console.log("Error in uploading user asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -130,7 +144,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { Edit image @@ -158,11 +172,9 @@ export const UserImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleDelete && ( - - )} +
diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index 614fe5f41..cc7248a62 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -1,23 +1,27 @@ "use client"; import React, { useState } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // hooks -import { useWorkspace, useInstance } from "@/hooks/store"; +import { useWorkspace } from "@/hooks/store"; // services import { FileService } from "@/services/file.service"; type Props = { - handleRemove?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; @@ -27,16 +31,15 @@ type Props = { const fileService = new FileService(); export const WorkspaceImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); // store hooks - const { config } = useInstance(); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, updateWorkspaceLogo } = useWorkspace(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -45,45 +48,58 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); const handleClose = () => { - setImage(null); setIsImageUploading(false); onClose(); + setTimeout(() => { + setImage(null); + }, 300); }; const handleSubmit = async () => { - if (!image || (!workspaceSlug && pathname !== "/onboarding")) return; - + if (!image || !workspaceSlug || !currentWorkspace) return; setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadWorkspaceAsset( + workspaceSlug.toString(), + { + entity_identifier: currentWorkspace.id, + entity_type: EFileAssetType.WORKSPACE_LOGO, + }, + image + ); + updateWorkspaceLogo(workspaceSlug.toString(), asset_url); + onSuccess(asset_url); + } catch (error) { + console.log("error", error); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - if (!workspaceSlug) return; - - fileService - .uploadFile(workspaceSlug.toString(), formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!workspaceSlug || !value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldWorkspaceAsset(currentWorkspace?.id ?? "", value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteWorkspaceAsset(workspaceSlug.toString(), assetId); + } + await handleRemove(); + handleClose(); + } catch (error) { + console.log("Error in removing workspace asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -115,7 +131,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {
- Upload Image + Upload image
@@ -136,7 +152,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { Edit image @@ -164,11 +180,9 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleRemove && ( - - )} +
diff --git a/web/core/components/core/render-if-visible-HOC.tsx b/web/core/components/core/render-if-visible-HOC.tsx index 3e697a53d..ea3fc9c2f 100644 --- a/web/core/components/core/render-if-visible-HOC.tsx +++ b/web/core/components/core/render-if-visible-HOC.tsx @@ -13,6 +13,7 @@ type Props = { defaultValue?: boolean; shouldRecordHeights?: boolean; useIdletime?: boolean; + forceRender?: boolean; }; const RenderIfVisible: React.FC = (props) => { @@ -29,12 +30,13 @@ const RenderIfVisible: React.FC = (props) => { placeholderChildren = null, //placeholder children defaultValue = false, useIdletime = false, + forceRender = false, } = props; const [shouldVisible, setShouldVisible] = useState(defaultValue); const placeholderHeight = useRef(defaultHeight); const intersectionRef = useRef(null); - const isVisible = shouldVisible; + const isVisible = shouldVisible || forceRender; // Set visibility with intersection observer useEffect(() => { diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index 1ee86a620..6fcdaebd5 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -17,15 +17,17 @@ import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; import { EIssuesStoreType } from "@/constants/issue"; -// helper +// helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useIssues } from "@/hooks/store"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; +// store import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { @@ -170,7 +172,12 @@ export const ActiveCycleStats: FC = observer((props) => { className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1" onClick={() => { if (issue.id) { - setPeekIssue({ workspaceSlug, projectId, issueId: issue.id }); + setPeekIssue({ + workspaceSlug, + projectId, + issueId: issue.id, + isArchived: !!issue.archived_at, + }); handleFiltersUpdate("priority", ["urgent", "high"], true); } }} @@ -250,7 +257,10 @@ export const ActiveCycleStats: FC = observer((props) => { key={assignee.assignee_id} title={
- + {assignee.display_name}
diff --git a/web/core/components/cycles/active-cycle/header.tsx b/web/core/components/cycles/active-cycle/header.tsx deleted file mode 100644 index 73d36f992..000000000 --- a/web/core/components/cycles/active-cycle/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import Link from "next/link"; -// types -import { ICycle, TCycleGroups } from "@plane/types"; -// ui -import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui"; -// helpers -import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper"; -import { truncateText } from "@/helpers/string.helper"; -// hooks -import { useMember } from "@/hooks/store"; - -export type ActiveCycleHeaderProps = { - cycle: ICycle; - workspaceSlug: string; - projectId: string; -}; - -export const ActiveCycleHeader: FC = (props) => { - const { cycle, workspaceSlug, projectId } = props; - // store - const { getUserDetails } = useMember(); - const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined; - - const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0; - const currentCycleStatus = cycle.status?.toLocaleLowerCase() as TCycleGroups | undefined; - - const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name); - - return ( -
-
- - -

{truncateText(cycle.name, 70)}

-
- - - {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} - - -
-
-
-
- - {cycleAssignee.length > 0 && ( - - - {cycleAssignee.map((member) => ( - - ))} - - - )} -
-
- - View Cycle - -
-
- ); -}; diff --git a/web/core/components/cycles/active-cycle/index.ts b/web/core/components/cycles/active-cycle/index.ts index c21978252..bf5f3e9b4 100644 --- a/web/core/components/cycles/active-cycle/index.ts +++ b/web/core/components/cycles/active-cycle/index.ts @@ -1,7 +1,3 @@ -export * from "./header"; -export * from "./stats"; -export * from "./upcoming-cycles-list-item"; -export * from "./upcoming-cycles-list"; export * from "./cycle-stats"; -export * from "./progress"; export * from "./productivity"; +export * from "./progress"; diff --git a/web/core/components/cycles/active-cycle/productivity.tsx b/web/core/components/cycles/active-cycle/productivity.tsx index 1e70f326f..74957af03 100644 --- a/web/core/components/cycles/active-cycle/productivity.tsx +++ b/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,8 +1,8 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types"; -import { CustomSelect, Loader } from "@plane/ui"; +import { ICycle, TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { EmptyState } from "@/components/empty-state"; @@ -11,6 +11,7 @@ import { EmptyStateType } from "@/constants/empty-state"; import { useCycle, useProjectEstimates } from "@/hooks/store"; // plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown"; export type ActiveCycleProductivityProps = { workspaceSlug: string; @@ -18,16 +19,10 @@ export type ActiveCycleProductivityProps = { cycle: ICycle | null; }; -const cycleBurnDownChartOptions = [ - { value: "issues", label: "Issues" }, - { value: "points", label: "Points" }, -]; - export const ActiveCycleProductivity: FC = observer((props) => { const { workspaceSlug, projectId, cycle } = props; // hooks const { getEstimateTypeByCycleId, setEstimateType } = useCycle(); - const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); // derived values const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues"; @@ -37,11 +32,6 @@ export const ActiveCycleProductivity: FC = observe setEstimateType(cycle.id, value); }; - const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - const estimateDetails = - isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); - const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; - const chartDistributionData = cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; @@ -52,22 +42,7 @@ export const ActiveCycleProductivity: FC = observe

Issue burndown

- {isCurrentEstimateTypeIsPoints && ( -
- {cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - > - {cycleBurnDownChartOptions.map((item) => ( - - {item.label} - - ))} - -
- )} +
diff --git a/web/core/components/cycles/active-cycle/stats.tsx b/web/core/components/cycles/active-cycle/stats.tsx deleted file mode 100644 index 1a91fdfc8..000000000 --- a/web/core/components/cycles/active-cycle/stats.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import React, { Fragment } from "react"; -import { Tab } from "@headlessui/react"; -import { ICycle } from "@plane/types"; -// hooks -import { Avatar } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import useLocalStorage from "@/hooks/use-local-storage"; -// components -// ui -// types - -type Props = { - cycle: ICycle; -}; - -export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { - const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - - default: - return 0; - } - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - - default: - return setTab("Assignees"); - } - }} - > - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Assignees - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Labels - - - {cycle && cycle.total_issues > 0 ? ( - - - {cycle.distribution?.assignees?.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - - {assignee?.display_name ?? ""} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
- User -
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - - {cycle.distribution?.labels?.map((label, index) => ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - /> - ))} - - - ) : ( -
- There are no issues present in this cycle. -
- )} - - ); -}; diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx deleted file mode 100644 index 4cdadac97..000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { Users } from "lucide-react"; -// ui -import { Avatar, AvatarGroup, FavoriteStar, setPromiseToast } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import { useCycle, useEventTracker, useMember } from "@/hooks/store"; - -type Props = { - cycleId: string; -}; - -export const UpcomingCycleListItem: React.FC = observer((props) => { - const { cycleId } = props; - // refs - const parentRef = useRef(null); - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { captureEvent } = useEventTracker(); - const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); - // derived values - const cycle = getCycleById(cycleId); - - const handleAddToFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - if (!cycle) return null; - - return ( - -
{cycle.name}
-
- {cycle.start_date && cycle.end_date && ( -
- {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} -
- )} - {cycle.assignee_ids && cycle.assignee_ids?.length > 0 ? ( - - {cycle.assignee_ids?.map((assigneeId) => { - const member = getUserDetails(assigneeId); - return ; - })} - - ) : ( - - )} - - { - if (cycle.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycle.is_favorite} - /> - - {workspaceSlug && projectId && ( - - )} -
- - ); -}); diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx deleted file mode 100644 index 221ffab0b..000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; -// components -import { UpcomingCycleListItem } from "@/components/cycles"; -// hooks -import { useCycle } from "@/hooks/store"; - -type Props = { - handleEmptyStateAction: () => void; -}; - -export const UpcomingCyclesList: FC = observer((props) => { - const { handleEmptyStateAction } = props; - // store hooks - const { currentProjectUpcomingCycleIds } = useCycle(); - - // theme - const { resolvedTheme } = useTheme(); - - const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`; - - if (!currentProjectUpcomingCycleIds) return null; - - return ( -
-
- Next cycles -
- {currentProjectUpcomingCycleIds.length > 0 ? ( -
- {currentProjectUpcomingCycleIds.map((cycleId) => ( - - ))} -
- ) : ( -
-
-
- -
-
No upcoming cycles
-

- Create new cycles to find them here or check -
- {"'"}All{"'"} cycles tab to see all cycles or{" "} - -

-
-
- )} -
- ); -}); diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 2f9b4b79e..d9725d1d6 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -1,14 +1,13 @@ "use client"; -import { FC, Fragment, useCallback, useMemo, useState } from "react"; +import { FC, Fragment, useCallback, useMemo } from "react"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types"; -import { CustomSelect } from "@plane/ui"; +import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; // components import { CycleProgressStats } from "@/components/cycles"; // constants @@ -16,18 +15,30 @@ import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getDate } from "@/helpers/date-time.helper"; // hooks -import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store"; -// plane web constants -import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar"; -import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { useIssues, useCycle } from "@/hooks/store"; +// plane web components +import { SidebarChartRoot } from "@/plane-web/components/cycles"; type TCycleAnalyticsProgress = { workspaceSlug: string; projectId: string; cycleId: string; }; +type Options = { + value: string; + label: string; +}; -const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { +export const cycleEstimateOptions: Options[] = [ + { value: "issues", label: "Issues" }, + { value: "points", label: "Points" }, +]; +export const cycleChartOptions: Options[] = [ + { value: "burndown", label: "Burn-down" }, + { value: "burnup", label: "Burn-up" }, +]; + +export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { if (!cycleDetails || cycleDetails === null) return cycleDetails; const updatedCycleDetails: any = { ...cycleDetails }; @@ -42,65 +53,25 @@ const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { return updatedCycleDetails; }; -type options = { - value: string; - label: string; -}; -export const cycleChartOptions: options[] = [ - { value: "burndown", label: "Burn-down" }, - { value: "burnup", label: "Burn-up" }, -]; -export const cycleEstimateOptions: options[] = [ - { value: "issues", label: "issues" }, - { value: "points", label: "points" }, -]; export const CycleAnalyticsProgress: FC = observer((props) => { // props const { workspaceSlug, projectId, cycleId } = props; // router const searchParams = useSearchParams(); const peekCycle = searchParams.get("peekCycle") || undefined; - // hooks - const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); - const { - getPlotTypeByCycleId, - getEstimateTypeByCycleId, - setPlotType, - getCycleById, - fetchCycleDetails, - fetchArchivedCycleDetails, - setEstimateType, - } = useCycle(); + const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); - // state - const [loader, setLoader] = useState(false); // derived values const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); const estimateType = getEstimateTypeByCycleId(cycleId); - const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - const estimateDetails = - isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); - const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; - const completedIssues = cycleDetails?.completed_issues || 0; const totalIssues = cycleDetails?.total_issues || 0; - const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0; const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; - const progressHeaderPercentage = cycleDetails - ? estimateType === "points" - ? completedEstimatePoints != 0 && totalEstimatePoints != 0 - ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) - : 0 - : completedIssues != 0 && completedIssues != 0 - ? Math.round((completedIssues / totalIssues) * 100) - : 0 - : 0; - const chartDistributionData = estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; @@ -125,25 +96,6 @@ export const CycleAnalyticsProgress: FC = observer((pro const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date(); const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate; const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid; - const isArchived = !!cycleDetails?.archived_at; - - // handlers - const onChange = async (value: TCycleEstimateType) => { - setEstimateType(cycleId, value); - if (!workspaceSlug || !projectId || !cycleId) return; - try { - setLoader(true); - if (isArchived) { - await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); - } else { - await fetchCycleDetails(workspaceSlug, projectId, cycleId); - } - setLoader(false); - } catch (error) { - setLoader(false); - setEstimateType(cycleId, estimateType); - } - }; const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { @@ -204,31 +156,7 @@ export const CycleAnalyticsProgress: FC = observer((pro -
- {cycleEstimateOptions.find((v) => v.value === estimateType)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - buttonClassName="border-none rounded text-sm font-medium" - > - {cycleEstimateOptions.map((item) => ( - - {item.label} - - ))} - -
-
- -
+ {/* progress detailed view */} {chartDistributionData && (
diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index e98871b40..069ccb2ff 100644 --- a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const CycleProgressStats: FC = observer((props) => { ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index 8bea7edfe..fd8c984a6 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -7,8 +7,8 @@ import { useParams } from "next/navigation"; import { Loader } from "@plane/ui"; // components import { CycleAnalyticsProgress, CycleSidebarHeader, CycleSidebarDetails } from "@/components/cycles"; -import useCyclesDetails from "../active-cycle/use-cycles-details"; // hooks +import useCyclesDetails from "../active-cycle/use-cycles-details"; type Props = { handleClose: () => void; diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index 0c6ad1fd8..c60b5dae9 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -3,13 +3,15 @@ import React, { FC } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { LayersIcon, SquareUser, Users } from "lucide-react"; -// ui +// plane types import { ICycle } from "@plane/types"; +// plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember, useProjectEstimates } from "@/hooks/store"; -// plane web +// plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; type Props = { @@ -72,7 +74,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
- + {cycleOwnerDetails?.display_name}
@@ -94,7 +96,7 @@ export const CycleSidebarDetails: FC = observer((props) => { ); diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index 6a654e053..af645804e 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -240,7 +240,7 @@ export const CycleSidebarHeader: FC = observer((props) => {

Archive cycle

- Only completed cycle
can be archived. + Only completed cycles
can be archived.

diff --git a/web/core/components/cycles/board/cycles-board-card.tsx b/web/core/components/cycles/board/cycles-board-card.tsx deleted file mode 100644 index 1f755089a..000000000 --- a/web/core/components/cycles/board/cycles-board-card.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { FC, MouseEvent, useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; -import { CalendarCheck2, CalendarClock, Info, MoveRight } from "lucide-react"; -// types -import type { TCycleGroups } from "@plane/types"; -// ui -import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast, FavoriteStar } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; -import { generateQueryParams } from "@/helpers/router.helper"; -// hooks -import { useEventTracker, useCycle, useMember, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; - -export interface ICyclesBoardCard { - workspaceSlug: string; - projectId: string; - cycleId: string; -} - -export const CyclesBoardCard: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; - // refs - const parentRef = useRef(null); - // router - const router = useAppRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - // store - const { captureEvent } = useEventTracker(); - const { allowPermissions } = useUserPermissions(); - - const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); - const { getUserDetails } = useMember(); - // computed - const cycleDetails = getCycleById(cycleId); - // hooks - const { isMobile } = usePlatformOS(); - - if (!cycleDetails) return null; - - const cycleStatus = cycleDetails.status?.toLocaleLowerCase(); - // const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - const isDateValid = cycleDetails.start_date || cycleDetails.end_date; - - const isEditingAllowed = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const issueCount = cycleDetails - ? cycleTotalIssues === 0 - ? "0 Issue" - : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` - : "0 Issue"; - - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - const openCycleOverview = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const query = generateQueryParams(searchParams, ["peekCycle"]); - if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); - } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); - } - }; - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - - return ( -
- -
-
-
- - - - - {cycleDetails.name} - -
-
- {currentCycle && ( - - {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} - - )} - -
-
- -
-
-
- - {issueCount} -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids.length > 0 && ( - -
- - {cycleDetails.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); - return ; - })} - -
-
- )} -
- - -
-
-
-
-
- - -
- {isDateValid && ( -
- - {renderFormattedDate(startDate)} - - - {renderFormattedDate(endDate)} -
- )} -
-
-
- -
- {isEditingAllowed && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - - -
-
- ); -}); diff --git a/web/core/components/cycles/board/cycles-board-map.tsx b/web/core/components/cycles/board/cycles-board-map.tsx deleted file mode 100644 index 3e83ca755..000000000 --- a/web/core/components/cycles/board/cycles-board-map.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// components -import { CyclesBoardCard } from "@/components/cycles"; - -type Props = { - cycleIds: string[]; - peekCycle: string | undefined; - projectId: string; - workspaceSlug: string; -}; - -export const CyclesBoardMap: React.FC = (props) => { - const { cycleIds, peekCycle, projectId, workspaceSlug } = props; - - return ( -
- {cycleIds.map((cycleId) => ( - - ))} -
- ); -}; diff --git a/web/core/components/cycles/board/index.ts b/web/core/components/cycles/board/index.ts deleted file mode 100644 index 2e6933d99..000000000 --- a/web/core/components/cycles/board/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./cycles-board-card"; -export * from "./cycles-board-map"; -export * from "./root"; diff --git a/web/core/components/cycles/board/root.tsx b/web/core/components/cycles/board/root.tsx deleted file mode 100644 index 1d4684fe5..000000000 --- a/web/core/components/cycles/board/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { ChevronRight } from "lucide-react"; -import { Disclosure } from "@headlessui/react"; -// components -import { CyclePeekOverview, CyclesBoardMap } from "@/components/cycles"; -// helpers -import { cn } from "@/helpers/common.helper"; - -export interface ICyclesBoard { - completedCycleIds: string[]; - cycleIds: string[]; - workspaceSlug: string; - projectId: string; - peekCycle: string | undefined; -} - -export const CyclesBoard: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; - - return ( -
-
-
- {cycleIds.length > 0 && ( - - )} - {completedCycleIds.length !== 0 && ( - - - {({ open }) => ( - <> - Completed cycles ({completedCycleIds.length}) - - - )} - - - - - - )} -
- -
-
- ); -}); diff --git a/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx new file mode 100644 index 000000000..7eba6418d --- /dev/null +++ b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { TCycleEstimateType } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; +import { useCycle, useProjectEstimates } from "@/hooks/store"; +import { cycleEstimateOptions } from "../analytics-sidebar"; + +type TProps = { + value: TCycleEstimateType; + onChange: (value: TCycleEstimateType) => Promise; + showDefault?: boolean; + projectId: string; + cycleId: string; +}; + +export const EstimateTypeDropdown = (props: TProps) => { + const { value, onChange, projectId, cycleId, showDefault = false } = props; + const { getIsPointsDataAvailable } = useCycle(); + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; + return getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled ? ( +
+ {cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}} + onChange={onChange} + maxHeight="lg" + buttonClassName="bg-custom-background-90 border-none rounded text-sm font-medium " + > + {cycleEstimateOptions.map((item) => ( + + {item.label} + + ))} + +
+ ) : showDefault ? ( + {value} + ) : null; +}; diff --git a/web/core/components/cycles/dropdowns/index.ts b/web/core/components/cycles/dropdowns/index.ts index 302e3a1a6..2d1f11554 100644 --- a/web/core/components/cycles/dropdowns/index.ts +++ b/web/core/components/cycles/dropdowns/index.ts @@ -1 +1,2 @@ export * from "./filters"; +export * from "./estimate-type-dropdown"; diff --git a/web/core/components/cycles/gantt-chart/blocks.tsx b/web/core/components/cycles/gantt-chart/blocks.tsx deleted file mode 100644 index 5c2ac815a..000000000 --- a/web/core/components/cycles/gantt-chart/blocks.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -// ui -import { Tooltip, ContrastIcon } from "@plane/ui"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import { useCycle } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { usePlatformOS } from "@/hooks/use-platform-os"; - -type Props = { - cycleId: string; -}; - -export const CycleGanttBlock: React.FC = observer((props) => { - const { cycleId } = props; - // router - const router = useAppRouter(); - // store hooks - const { workspaceSlug } = useParams(); - const { getCycleById } = useCycle(); - // derived values - const cycleDetails = getCycleById(cycleId); - const { isMobile } = usePlatformOS(); - const cycleStatus = cycleDetails?.status?.toLocaleLowerCase(); - - return ( -
- router.push(`/${workspaceSlug?.toString()}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`) - } - > -
- -
{cycleDetails?.name}
-
- {renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "} - {renderFormattedDate(cycleDetails?.end_date ?? "")} -
-
- } - position="top-left" - > -
{cycleDetails?.name}
- -
- ); -}); - -export const CycleGanttSidebarBlock: React.FC = observer((props) => { - const { cycleId } = props; - // store hooks - const { workspaceSlug } = useParams(); - const { getCycleById } = useCycle(); - // derived values - const cycleDetails = getCycleById(cycleId); - - const cycleStatus = cycleDetails?.status?.toLocaleLowerCase(); - - return ( - - -
{cycleDetails?.name}
- - ); -}); diff --git a/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx deleted file mode 100644 index 5f147e0bc..000000000 --- a/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { FC, useCallback } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { ICycle } from "@plane/types"; -// components -import { CycleGanttBlock } from "@/components/cycles"; -import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar, ChartDataType } from "@/components/gantt-chart"; -import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views"; -// helpers -import { getDate } from "@/helpers/date-time.helper"; -// hooks -import { useCycle } from "@/hooks/store"; - -type Props = { - workspaceSlug: string; - cycleIds: string[]; -}; - -export const CyclesListGanttChartView: FC = observer((props) => { - const { cycleIds } = props; - // router - const { workspaceSlug } = useParams(); - // store hooks - const { getCycleById, updateCycleDetails } = useCycle(); - - const getBlockById = useCallback( - (id: string, currentViewData?: ChartDataType | undefined) => { - const cycle = getCycleById(id); - const block = { - data: cycle, - id: cycle?.id ?? "", - sort_order: cycle?.sort_order ?? 0, - start_date: getDate(cycle?.start_date), - target_date: getDate(cycle?.end_date), - }; - - if (currentViewData) { - return { - ...block, - position: getMonthChartItemPositionWidthInMonth(currentViewData, block), - }; - } - return block; - }, - [getCycleById] - ); - - const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { - if (!workspaceSlug || !cycle) return; - - const payload: any = { ...data }; - if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - - await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload); - }; - - return ( -
- handleCycleUpdate(block, payload)} - sidebarToRender={(props) => } - blockToRender={(data: ICycle) => } - enableBlockLeftResize={false} - enableBlockRightResize={false} - enableBlockMove={false} - enableReorder - /> -
- ); -}); diff --git a/web/core/components/cycles/gantt-chart/index.ts b/web/core/components/cycles/gantt-chart/index.ts deleted file mode 100644 index a0a16086b..000000000 --- a/web/core/components/cycles/gantt-chart/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./blocks"; -export * from "./cycles-list-layout"; diff --git a/web/core/components/cycles/index.ts b/web/core/components/cycles/index.ts index f286b39e6..7013beeab 100644 --- a/web/core/components/cycles/index.ts +++ b/web/core/components/cycles/index.ts @@ -1,8 +1,6 @@ export * from "./active-cycle"; export * from "./applied-filters"; -export * from "./board/"; export * from "./dropdowns"; -export * from "./gantt-chart"; export * from "./list"; export * from "./cycle-peek-overview"; export * from "./cycles-view-header"; diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 5782881b1..989e0436e 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -18,12 +18,15 @@ import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; // helpers import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { generateQueryParams } from "@/helpers/router.helper"; import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// services import { CycleService } from "@/services/cycle.service"; const cycleService = new CycleService(); @@ -208,7 +211,7 @@ export const CycleListItemAction: FC = observer((props) => { <>
@@ -223,7 +223,9 @@ export const CreatedUpcomingIssueListItem: React.FC = observ if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -281,7 +283,9 @@ export const CreatedOverdueIssueListItem: React.FC = observe if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -334,7 +338,9 @@ export const CreatedCompletedIssueListItem: React.FC = obser if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( diff --git a/web/core/components/dashboard/widgets/recent-activity.tsx b/web/core/components/dashboard/widgets/recent-activity.tsx index bc81f57c9..dd21815cc 100644 --- a/web/core/components/dashboard/widgets/recent-activity.tsx +++ b/web/core/components/dashboard/widgets/recent-activity.tsx @@ -13,6 +13,7 @@ import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/component // helpers import { cn } from "@/helpers/common.helper"; import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useDashboard, useUser } from "@/hooks/store"; @@ -54,9 +55,9 @@ export const RecentActivityWidget: React.FC = observer((props) => {
) - ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( + ) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? ( = observer((prop
= (props) => { const { dashboardId, searchQuery = "", workspaceSlug } = props; + + // state + const [visibleItems, setVisibleItems] = useState(16); + const [isExpanded, setIsExpanded] = useState(false); // store hooks const { fetchWidgetStats } = useDashboard(); const { getUserDetails } = useMember(); @@ -88,8 +94,10 @@ export const CollaboratorsList: React.FC = (props) => { const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]); const filteredStats = sortedStats.filter((user) => { - const { display_name, first_name, last_name } = getUserDetails(user?.user_id) || {}; - + if (!user) return false; + const userDetails = getUserDetails(user?.user_id); + if (!userDetails || userDetails.is_bot) return false; + const { display_name, first_name, last_name } = userDetails; const searchLower = searchQuery.toLowerCase(); return ( display_name?.toLowerCase().includes(searchLower) || @@ -98,16 +106,49 @@ export const CollaboratorsList: React.FC = (props) => { ); }); + // Update the displayedStats to always use the visibleItems limit + const handleLoadMore = () => { + setVisibleItems((prev) => { + const newValue = prev + 16; + if (newValue >= filteredStats.length) { + setIsExpanded(true); + return filteredStats.length; + } + return newValue; + }); + }; + + const handleHide = () => { + setVisibleItems(16); + setIsExpanded(false); + }; + + const displayedStats = filteredStats.slice(0, visibleItems); + return ( -
- {filteredStats?.map((user) => ( - - ))} -
+ <> +
+ {displayedStats?.map((user) => ( + + ))} +
+ {filteredStats.length > visibleItems && !isExpanded && ( +
+
+ Load more +
+
+ )} + {isExpanded && ( +
+
Hide
+
+ )} + ); }; diff --git a/web/core/components/dashboard/widgets/recent-projects.tsx b/web/core/components/dashboard/widgets/recent-projects.tsx index a390f3ac2..525590871 100644 --- a/web/core/components/dashboard/widgets/recent-projects.tsx +++ b/web/core/components/dashboard/widgets/recent-projects.tsx @@ -4,18 +4,20 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { Plus } from "lucide-react"; -// types +// plane types import { TRecentProjectsWidgetResponse } from "@plane/types"; -// ui +// plane ui import { Avatar, AvatarGroup, Card } from "@plane/ui"; - // components import { Logo } from "@/components/common"; import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets"; // constants import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useEventTracker, useDashboard, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WIDGET_KEY = "recent_projects"; @@ -51,7 +53,11 @@ const ProjectListItem: React.FC = observer((props) => {
{projectDetails.members?.map((member) => ( - + ))}
diff --git a/web/core/components/dropdowns/member/avatar.tsx b/web/core/components/dropdowns/member/avatar.tsx index 50e3ae599..0a7a92d43 100644 --- a/web/core/components/dropdowns/member/avatar.tsx +++ b/web/core/components/dropdowns/member/avatar.tsx @@ -1,10 +1,11 @@ "use client"; import { observer } from "mobx-react"; -// icons import { LucideIcon, Users } from "lucide-react"; -// ui +// plane ui import { Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember } from "@/hooks/store"; @@ -27,14 +28,21 @@ export const ButtonAvatars: React.FC = observer((props) => { const userDetails = getUserDetails(userId); if (!userDetails) return; - return ; + return ; })} ); } else { if (userIds) { const userDetails = getUserDetails(userIds); - return ; + return ( + + ); } } diff --git a/web/core/components/dropdowns/member/member-options.tsx b/web/core/components/dropdowns/member/member-options.tsx index bf14e14a6..a2283fa4b 100644 --- a/web/core/components/dropdowns/member/member-options.tsx +++ b/web/core/components/dropdowns/member/member-options.tsx @@ -8,15 +8,17 @@ import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -//components -import { cn } from "@plane/editor"; +// plane ui import { Avatar } from "@plane/ui"; -//store +// helpers +import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useUser, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; interface Props { - className? : string; + className?: string; optionsClassName?: string; projectId?: string; referenceElement: HTMLButtonElement | null; @@ -24,8 +26,8 @@ interface Props { isOpen: boolean; } -export const MemberOptions = observer((props: Props) => { - const { projectId, referenceElement, placement, isOpen, optionsClassName="" } = props; +export const MemberOptions: React.FC = observer((props: Props) => { + const { projectId, referenceElement, placement, isOpen, optionsClassName = "" } = props; // states const [query, setQuery] = useState(""); const [popperElement, setPopperElement] = useState(null); @@ -82,7 +84,7 @@ export const MemberOptions = observer((props: Props) => { query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, content: (
- + {currentUser?.id === userId ? "You" : userDetails?.display_name}
), @@ -95,8 +97,10 @@ export const MemberOptions = observer((props: Props) => { return createPortal(
{ const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === priority); const priorityClasses = { - urgent: "bg-red-500/20 text-red-950 border-red-500", + urgent: "bg-red-600/10 text-red-600 border-red-600 px-1", high: "bg-orange-500/20 text-orange-950 border-orange-500", medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500", low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100", @@ -88,7 +88,7 @@ const BorderButton = (props: ButtonProps) => { // compact the icons if text is hidden "px-0.5": hideText, // highlight the whole button if text is hidden and priority is urgent - "bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent, + "bg-red-600/10 border-red-600": priority === "urgent" && hideText && highlightUrgent, }, className )} @@ -98,7 +98,8 @@ const BorderButton = (props: ButtonProps) => {
{ "translate-x-0.5": hideText && priority === "medium", "translate-x-1": hideText && priority === "low", // highlight the icon if priority is urgent - "text-white": priority === "urgent" && highlightUrgent, })} />
@@ -145,7 +145,7 @@ const BackgroundButton = (props: ButtonProps) => { const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === priority); const priorityClasses = { - urgent: "bg-red-500/20 text-red-950", + urgent: "bg-red-600/20 text-red-600", high: "bg-orange-500/20 text-orange-950", medium: "bg-yellow-500/20 text-yellow-950", low: "bg-blue-500/20 text-blue-950", @@ -170,7 +170,7 @@ const BackgroundButton = (props: ButtonProps) => { // compact the icons if text is hidden "px-0.5": hideText, // highlight the whole button if text is hidden and priority is urgent - "bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent, + "bg-red-600/10 border-red-600": priority === "urgent" && hideText && highlightUrgent, }, className )} @@ -180,7 +180,8 @@ const BackgroundButton = (props: ButtonProps) => {
{ "translate-x-0.5": hideText && priority === "medium", "translate-x-1": hideText && priority === "low", // highlight the icon if priority is urgent - "text-white": priority === "urgent" && highlightUrgent, })} />
@@ -253,7 +253,7 @@ const TransparentButton = (props: ButtonProps) => { // compact the icons if text is hidden "px-0.5": hideText, // highlight the whole button if text is hidden and priority is urgent - "bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent, + "bg-red-600/10 border-red-600": priority === "urgent" && hideText && highlightUrgent, "bg-custom-background-80": isActive, }, className @@ -264,7 +264,8 @@ const TransparentButton = (props: ButtonProps) => {
{ "translate-x-0.5": hideText && priority === "medium", "translate-x-1": hideText && priority === "low", // highlight the icon if priority is urgent - "text-white": priority === "urgent" && highlightUrgent, })} />
diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index 3156fb396..3f973cd16 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -3,8 +3,6 @@ import { observer } from "mobx-react"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -// types -import { IProject } from "@plane/types"; // ui import { ComboDropDown } from "@plane/ui"; // components @@ -14,6 +12,8 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useProject } from "@/hooks/store"; import { useDropdown } from "@/hooks/use-dropdown"; +// plane web types +import { TProject } from "@/plane-web/types"; // components import { DropdownButton } from "./buttons"; // constants @@ -27,7 +27,7 @@ type Props = TDropdownProps & { dropdownArrowClassName?: string; onChange: (val: string) => void; onClose?: () => void; - renderCondition?: (project: IProject) => boolean; + renderCondition?: (project: TProject) => boolean; value: string | null; renderByDefault?: boolean; }; diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index 72e92a6a8..0b14bd135 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -1,2 +1,3 @@ export * from "./lite-text-editor"; +export * from "./pdf"; export * from "./rich-text-editor"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 8036e4c8d..0822f1a97 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -9,11 +9,12 @@ import { IssueCommentToolbar } from "@/components/editor"; import { EIssueCommentAccessSpecifier } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMember, useMention, useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; interface LiteTextEditorWrapperProps extends Omit { workspaceSlug: string; @@ -24,10 +25,9 @@ interface LiteTextEditorWrapperProps extends Omit Promise; } -const fileService = new FileService(); - export const LiteTextEditor = React.forwardRef((props, ref) => { const { containerClassName, @@ -40,6 +40,7 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } + // derived values + const isEmpty = isCommentEmpty(props.initialValue); + const editorRef = isMutableRefObject(ref) ? ref.current : null; return (
{ - if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); - } + executeCommand={(item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); }} handleAccessChange={handleAccessChange} handleSubmit={(e) => rest.onEnterKeyPress?.(e)} isCommentEmpty={isEmpty} isSubmitting={isSubmitting} showAccessSpecifier={showAccessSpecifier} - editorRef={isMutableRefObject(ref) ? ref : null} + editorRef={editorRef} showSubmitButton={showSubmitButton} />
diff --git a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx index 0dd2b1bd3..e2585a472 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx @@ -3,13 +3,17 @@ import React from "react"; import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention, useUser } from "@/hooks/store"; -type LiteTextReadOnlyEditorWrapperProps = Omit; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { // store hooks const { data: currentUser } = useUser(); const { mentionHighlights } = useMention({ @@ -19,6 +23,10 @@ export const LiteTextReadOnlyEditor = React.forwardRef void; + executeCommand: (item: ToolbarMenuItem) => void; handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; handleSubmit: (event: React.MouseEvent) => void; isCommentEmpty: boolean; isSubmitting: boolean; showAccessSpecifier: boolean; showSubmitButton: boolean; - editorRef: React.MutableRefObject | null; + editorRef: EditorRefApi | null; }; type TCommentAccessType = { @@ -63,22 +63,25 @@ export const IssueCommentToolbar: React.FC = (props) => { // Function to update active states const updateActiveStates = useCallback(() => { - if (editorRef?.current) { - const newActiveStates: Record = {}; - Object.values(toolbarItems) - .flat() - .forEach((item) => { - // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, }); - setActiveStates(newActiveStates); - } + }); + setActiveStates(newActiveStates); }, [editorRef]); // useEffect to call updateActiveStates when isActive prop changes useEffect(() => { - if (!editorRef?.current) return; - const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); updateActiveStates(); return () => unsubscribe(); }, [editorRef, updateActiveStates]); @@ -120,35 +123,39 @@ export const IssueCommentToolbar: React.FC = (props) => { "pl-0": index === 0, })} > - {toolbarItems[key].map((item) => ( - - {item.name} - {item.shortcut && {item.shortcut.join(" + ")}} -

- } - > - -
- ))} + + + ); + })}
))}
diff --git a/web/core/components/editor/pdf/document.tsx b/web/core/components/editor/pdf/document.tsx new file mode 100644 index 000000000..4dca9e6d5 --- /dev/null +++ b/web/core/components/editor/pdf/document.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Document, Font, Page, PageProps } from "@react-pdf/renderer"; +import { Html } from "react-pdf-html"; +// constants +import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor"; + +Font.register({ + family: "Inter", + fonts: [ + { src: "/fonts/inter/thin.ttf", fontWeight: "thin" }, + { src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" }, + ], +}); + +type Props = { + content: string; + pageFormat: PageProps["size"]; +}; + +export const PDFDocument: React.FC = (props) => { + const { content, pageFormat } = props; + + return ( + + + {content} + + + ); +}; diff --git a/web/core/components/editor/pdf/index.ts b/web/core/components/editor/pdf/index.ts new file mode 100644 index 000000000..fe6d89c0e --- /dev/null +++ b/web/core/components/editor/pdf/index.ts @@ -0,0 +1 @@ +export * from "./document"; diff --git a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx index bb2966937..fb734bc8d 100644 --- a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx +++ b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx @@ -5,21 +5,21 @@ import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/edi import { IUserLite } from "@plane/types"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMember, useMention, useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; interface RichTextEditorWrapperProps extends Omit { workspaceSlug: string; workspaceId: string; projectId: string; + uploadFile: (file: File) => Promise; } -const fileService = new FileService(); - export const RichTextEditor = forwardRef((props, ref) => { - const { containerClassName, workspaceSlug, workspaceId, projectId, ...rest } = props; + const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props; // store hooks const { data: currentUser } = useUser(); const { @@ -36,22 +36,25 @@ export const RichTextEditor = forwardRef ); }); diff --git a/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx index 987897ec8..21e656ae6 100644 --- a/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx +++ b/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx @@ -3,18 +3,26 @@ import React from "react"; import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention } from "@/hooks/store"; -type RichTextReadOnlyEditorWrapperProps = Omit; +type RichTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId?: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { const { mentionHighlights } = useMention({}); return ( void; + handleScrollToBlock: (block: IGanttBlock) => void; + enableAddBlock: boolean | ((blockId: string) => boolean); + showAllBlocks: boolean; + selectionHelpers: TSelectionHelper; + ganttContainerRef: React.RefObject; +}; + +export const GanttChartRowList: FC = (props) => { + const { + blockIds, + blockUpdateHandler, + handleScrollToBlock, + enableAddBlock, + showAllBlocks, + selectionHelpers, + ganttContainerRef, + } = props; + + return ( +
+ {blockIds?.map((blockId) => ( + <> + } + shouldRecordHeights={false} + > + + + + ))} +
+ ); +}; diff --git a/web/core/components/gantt-chart/blocks/block-row.tsx b/web/core/components/gantt-chart/blocks/block-row.tsx new file mode 100644 index 000000000..b215aee4b --- /dev/null +++ b/web/core/components/gantt-chart/blocks/block-row.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { ArrowRight } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIssueDetail } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { BLOCK_HEIGHT, SIDEBAR_WIDTH } from "../constants"; +import { ChartAddBlock } from "../helpers"; +import { IBlockUpdateData, IGanttBlock } from "../types"; + +type Props = { + blockId: string; + showAllBlocks: boolean; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + handleScrollToBlock: (block: IGanttBlock) => void; + enableAddBlock: boolean; + selectionHelpers: TSelectionHelper; + ganttContainerRef: React.RefObject; +}; + +export const BlockRow: React.FC = observer((props) => { + const { blockId, showAllBlocks, blockUpdateHandler, handleScrollToBlock, enableAddBlock, selectionHelpers } = props; + // states + const [isHidden, setIsHidden] = useState(false); + const [isBlockHiddenOnLeft, setIsBlockHiddenOnLeft] = useState(false); + // store hooks + const { getBlockById, updateActiveBlockId, isBlockActive } = useTimeLineChartStore(); + const { getIsIssuePeeked } = useIssueDetail(); + + const block = getBlockById(blockId); + + useEffect(() => { + const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; + const timelineBlock = document.getElementById(`gantt-block-${block?.id}`); + if (!timelineBlock || !intersectionRoot) return; + + setIsBlockHiddenOnLeft( + !!block.position?.marginLeft && + !!block.position?.width && + intersectionRoot.scrollLeft > block.position.marginLeft + block.position.width + ); + + // Observe if the block is visible on the chart + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + setIsHidden(!entry.isIntersecting); + setIsBlockHiddenOnLeft(entry.boundingClientRect.right < (entry.rootBounds?.left ?? 0)); + }); + }, + { + root: intersectionRoot, + rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`, + } + ); + + observer.observe(timelineBlock); + + return () => { + observer.unobserve(timelineBlock); + }; + }, [block]); + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!block || !block.data || (!showAllBlocks && !(block.start_date && block.target_date))) return null; + + const isBlockVisibleOnChart = block.start_date || block.target_date; + const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id); + const isBlockFocused = selectionHelpers.getIsEntityActive(block.id); + const isBlockHoveredOn = isBlockActive(block.id); + + return ( +
updateActiveBlockId(blockId)} + onMouseLeave={() => updateActiveBlockId(null)} + style={{ + height: `${BLOCK_HEIGHT}px`, + }} + > +
+ {isBlockVisibleOnChart + ? isHidden && ( + + ) + : enableAddBlock && } +
+
+ ); +}); diff --git a/web/core/components/gantt-chart/blocks/block.tsx b/web/core/components/gantt-chart/blocks/block.tsx index 805ea9876..8671993c7 100644 --- a/web/core/components/gantt-chart/blocks/block.tsx +++ b/web/core/components/gantt-chart/blocks/block.tsx @@ -1,124 +1,101 @@ +import { RefObject, useRef } from "react"; import { observer } from "mobx-react"; +// components +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; // helpers import { cn } from "@/helpers/common.helper"; -import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks -import { useIssueDetail } from "@/hooks/store"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // constants import { BLOCK_HEIGHT } from "../constants"; // components -import { ChartAddBlock, ChartDraggable } from "../helpers"; -import { useGanttChart } from "../hooks"; -import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; +import { ChartDraggable } from "../helpers"; +import { useGanttResizable } from "../helpers/blockResizables/use-gantt-resizable"; +import { IBlockUpdateDependencyData } from "../types"; type Props = { blockId: string; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; showAllBlocks: boolean; blockToRender: (data: any) => React.ReactNode; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; - enableAddBlock: boolean; - ganttContainerRef: React.RefObject; - selectionHelpers: TSelectionHelper; + ganttContainerRef: RefObject; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; }; export const GanttChartBlock: React.FC = observer((props) => { const { blockId, - getBlockById, showAllBlocks, blockToRender, - blockUpdateHandler, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, - enableAddBlock, ganttContainerRef, - selectionHelpers, + updateBlockDates, } = props; // store hooks - const { currentViewData, updateActiveBlockId, isBlockActive } = useGanttChart(); - const { getIsIssuePeeked } = useIssueDetail(); + const { updateActiveBlockId, getBlockById, getIsCurrentDependencyDragging, currentView } = useTimeLineChartStore(); + // refs + const resizableRef = useRef(null); - const block = getBlockById(blockId, currentViewData); + const block = getBlockById(blockId); + + const isCurrentDependencyDragging = getIsCurrentDependencyDragging(blockId); + + const { isMoving, handleBlockDrag } = useGanttResizable(block, resizableRef, ganttContainerRef, updateBlockDates); + + const isBlockVisibleOnChart = block?.start_date || block?.target_date; + const isBlockComplete = block?.start_date && block?.target_date; // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!block || (!showAllBlocks && !(block.start_date && block.target_date))) return null; - - const isBlockVisibleOnChart = block.start_date && block.target_date; - - const handleChartBlockPosition = ( - block: IGanttBlock, - totalBlockShifts: number, - dragDirection: "left" | "right" | "move" - ) => { - const originalStartDate = getDate(block.start_date); - const originalTargetDate = getDate(block.target_date); - - if (!originalStartDate || !originalTargetDate) return; - - const updatedStartDate = new Date(originalStartDate); - const updatedTargetDate = new Date(originalTargetDate); - - // update the start date on left resize - if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); - // update the target date on right resize - else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - // update both the dates on x-axis move - else if (dragDirection === "move") { - updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); - updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - } - - // call the block update handler with the updated dates - blockUpdateHandler(block.data, { - start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, - target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, - }); - }; + if (!block || (!showAllBlocks && !isBlockVisibleOnChart)) return null; if (!block.data) return null; - const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id); - const isBlockFocused = selectionHelpers.getIsEntityActive(block.id); - const isBlockHoveredOn = isBlockActive(block.id); - return (
-
updateActiveBlockId(blockId)} - onMouseLeave={() => updateActiveBlockId(null)} - > - {isBlockVisibleOnChart ? ( - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - ganttContainerRef={ganttContainerRef} - /> - ) : ( - enableAddBlock && - )} -
+ {isBlockVisibleOnChart && ( + } + shouldRecordHeights={false} + forceRender={isCurrentDependencyDragging} + > +
updateActiveBlockId(blockId)} + onMouseLeave={() => updateActiveBlockId(null)} + > + +
+
+ )}
); }); diff --git a/web/core/components/gantt-chart/blocks/blocks-list.tsx b/web/core/components/gantt-chart/blocks/blocks-list.tsx index c4ffae138..8015819d7 100644 --- a/web/core/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/core/components/gantt-chart/blocks/blocks-list.tsx @@ -1,60 +1,39 @@ import { FC } from "react"; -// hooks -import { TSelectionHelper } from "@/hooks/use-multiple-select"; -// constants -import { HEADER_HEIGHT } from "../constants"; -// types -import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; -// components +// +import { IBlockUpdateDependencyData } from "../types"; import { GanttChartBlock } from "./block"; export type GanttChartBlocksProps = { - itemsContainerWidth: number; blockIds: string[]; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; blockToRender: (data: any) => React.ReactNode; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; enableBlockLeftResize: boolean | ((blockId: string) => boolean); enableBlockRightResize: boolean | ((blockId: string) => boolean); enableBlockMove: boolean | ((blockId: string) => boolean); - enableAddBlock: boolean | ((blockId: string) => boolean); ganttContainerRef: React.RefObject; showAllBlocks: boolean; - selectionHelpers: TSelectionHelper; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; }; export const GanttChartBlocksList: FC = (props) => { const { - itemsContainerWidth, blockIds, blockToRender, - blockUpdateHandler, - getBlockById, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, - enableAddBlock, ganttContainerRef, showAllBlocks, - selectionHelpers, + updateBlockDates, } = props; return ( -
+ <> {blockIds?.map((blockId) => ( = (props) => { typeof enableBlockRightResize === "function" ? enableBlockRightResize(blockId) : enableBlockRightResize } enableBlockMove={typeof enableBlockMove === "function" ? enableBlockMove(blockId) : enableBlockMove} - enableAddBlock={typeof enableAddBlock === "function" ? enableAddBlock(blockId) : enableAddBlock} ganttContainerRef={ganttContainerRef} - selectionHelpers={selectionHelpers} + updateBlockDates={updateBlockDates} /> ))} -
+ ); }; diff --git a/web/core/components/gantt-chart/chart/header.tsx b/web/core/components/gantt-chart/chart/header.tsx index 9159050ea..5a8f4bb65 100644 --- a/web/core/components/gantt-chart/chart/header.tsx +++ b/web/core/components/gantt-chart/chart/header.tsx @@ -1,14 +1,16 @@ import { observer } from "mobx-react"; import { Expand, Shrink } from "lucide-react"; -// hooks -// helpers +// plane import { Row } from "@plane/ui"; +// components import { VIEWS_LIST } from "@/components/gantt-chart/data"; +// helpers import { cn } from "@/helpers/common.helper"; -// types -import { useGanttChart } from "../hooks/use-gantt-chart"; +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { GANTT_BREADCRUMBS_HEIGHT } from "../constants"; import { TGanttViews } from "../types"; -// constants type Props = { blockIds: string[]; @@ -24,10 +26,13 @@ export const GanttChartHeader: React.FC = observer((props) => { const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } = props; // chart hook - const { currentView } = useGanttChart(); + const { currentView } = useTimeLineChartStore(); return ( - +
{blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."} diff --git a/web/core/components/gantt-chart/chart/main-content.tsx b/web/core/components/gantt-chart/chart/main-content.tsx index bb55fbb34..b9582d21c 100644 --- a/web/core/components/gantt-chart/chart/main-content.tsx +++ b/web/core/components/gantt-chart/chart/main-content.tsx @@ -5,36 +5,38 @@ import { observer } from "mobx-react"; // components import { MultipleSelectGroup } from "@/components/core"; import { - BiWeekChartView, ChartDataType, - DayChartView, GanttChartBlocksList, GanttChartSidebar, - HourChartView, IBlockUpdateData, + IBlockUpdateDependencyData, IGanttBlock, MonthChartView, QuarterChartView, TGanttViews, WeekChartView, - YearChartView, } from "@/components/gantt-chart"; // helpers import { cn } from "@/helpers/common.helper"; +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // plane web components +import { TimelineDependencyPaths, TimelineDraggablePath } from "@/plane-web/components/gantt-chart"; import { IssueBulkOperationsRoot } from "@/plane-web/components/issues"; // plane web hooks import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status"; -// constants -import { GANTT_SELECT_GROUP } from "../constants"; -// hooks -import { useGanttChart } from "../hooks/use-gantt-chart"; +// +import { GanttChartRowList } from "../blocks/block-row-list"; +import { DEFAULT_BLOCK_WIDTH, GANTT_SELECT_GROUP, HEADER_HEIGHT } from "../constants"; +import { getItemPositionWidth } from "../views"; +import { TimelineDragHelper } from "./timeline-drag-helper"; type Props = { blockIds: string[]; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; canLoadMoreBlocks?: boolean; loadMoreBlocks?: () => void; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; bottomSpacing: boolean; @@ -48,14 +50,17 @@ type Props = { showAllBlocks: boolean; sidebarToRender: (props: any) => React.ReactNode; title: string; - updateCurrentViewRenderPayload: (direction: "left" | "right", currentView: TGanttViews) => void; + updateCurrentViewRenderPayload: ( + direction: "left" | "right", + currentView: TGanttViews, + targetDate?: Date + ) => ChartDataType | undefined; quickAdd?: React.JSX.Element | undefined; }; export const GanttChartMainContent: React.FC = observer((props) => { const { blockIds, - getBlockById, loadMoreBlocks, blockToRender, blockUpdateHandler, @@ -73,11 +78,12 @@ export const GanttChartMainContent: React.FC = observer((props) => { canLoadMoreBlocks, updateCurrentViewRenderPayload, quickAdd, + updateBlockDates, } = props; // refs const ganttContainerRef = useRef(null); // chart hook - const { currentView, currentViewData } = useGanttChart(); + const { currentView, currentViewData } = useTimeLineChartStore(); // plane web hooks const isBulkOperationsEnabled = useBulkOperationStatus(); @@ -91,96 +97,137 @@ export const GanttChartMainContent: React.FC = observer((props) => { autoScrollForElements({ element, getAllowedAxis: () => "vertical", + canScroll: ({ source }) => source.data.dragInstanceId === "GANTT_REORDER", }) ); }, [ganttContainerRef?.current]); + // handling scroll functionality const onScroll = (e: React.UIEvent) => { const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget; - // updateScrollLeft(scrollLeft); - - const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth; + const approxRangeLeft = scrollLeft; const approxRangeRight = scrollWidth - (scrollLeft + clientWidth); + const calculatedRangeRight = itemsContainerWidth - (scrollLeft + clientWidth); - if (approxRangeRight < 1000) updateCurrentViewRenderPayload("right", currentView); - if (approxRangeLeft < 1000) updateCurrentViewRenderPayload("left", currentView); + if (approxRangeRight < clientWidth || calculatedRangeRight < clientWidth) { + updateCurrentViewRenderPayload("right", currentView); + } + if (approxRangeLeft < clientWidth) { + updateCurrentViewRenderPayload("left", currentView); + } + }; + + const handleScrollToBlock = (block: IGanttBlock) => { + const scrollContainer = ganttContainerRef.current as HTMLDivElement; + const scrollToEndDate = !block.start_date && block.target_date; + const scrollToDate = block.start_date ? getDate(block.start_date) : getDate(block.target_date); + let chartData; + + if (!scrollContainer || !currentViewData || !scrollToDate) return; + + if (scrollToDate.getTime() < currentViewData.data.startDate.getTime()) { + chartData = updateCurrentViewRenderPayload("left", currentView, scrollToDate); + } else if (scrollToDate.getTime() > currentViewData.data.endDate.getTime()) { + chartData = updateCurrentViewRenderPayload("right", currentView, scrollToDate); + } + // update container's scroll position to the block's position + const updatedPosition = getItemPositionWidth(chartData ?? currentViewData, block); + + setTimeout(() => { + if (updatedPosition) + scrollContainer.scrollLeft = updatedPosition.marginLeft - 4 - (scrollToEndDate ? DEFAULT_BLOCK_WIDTH : 0); + }); }; const CHART_VIEW_COMPONENTS: { [key in TGanttViews]: React.FC; } = { - hours: HourChartView, - day: DayChartView, week: WeekChartView, - bi_week: BiWeekChartView, month: MonthChartView, quarter: QuarterChartView, - year: YearChartView, }; if (!currentView) return null; const ActiveChartView = CHART_VIEW_COMPONENTS[currentView]; return ( - - {(helpers) => ( - <> -
- -
- - {currentViewData && ( - + <> + + + {(helpers) => ( + <> +
+ +
+ + {currentViewData && ( +
+ + + + +
+ )} +
-
- - - )} - + + + )} + + ); }); diff --git a/web/core/components/gantt-chart/chart/root.tsx b/web/core/components/gantt-chart/chart/root.tsx index 3b42e81b6..fa525f13a 100644 --- a/web/core/components/gantt-chart/chart/root.tsx +++ b/web/core/components/gantt-chart/chart/root.tsx @@ -1,19 +1,24 @@ import { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; -// hooks // components import { GanttChartHeader, GanttChartMainContent } from "@/components/gantt-chart"; -// views // helpers import { cn } from "@/helpers/common.helper"; -// types -// data +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// import { SIDEBAR_WIDTH } from "../constants"; import { currentViewDataWithView } from "../data"; -// constants -import { useGanttChart } from "../hooks/use-gantt-chart"; -import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; -import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth } from "../views"; +import { ChartDataType, IBlockUpdateData, IBlockUpdateDependencyData, TGanttViews } from "../types"; +import { + getNumberOfDaysBetweenTwoDates, + IMonthBlock, + IMonthView, + IWeekBlock, + monthView, + quarterView, + weekView, +} from "../views"; type ChartViewRootProps = { border: boolean; @@ -31,19 +36,24 @@ type ChartViewRootProps = { enableSelection: boolean | ((blockId: string) => boolean); bottomSpacing: boolean; showAllBlocks: boolean; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; loadMoreBlocks?: () => void; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; canLoadMoreBlocks?: boolean; quickAdd?: React.JSX.Element | undefined; showToday: boolean; }; +const timelineViewHelpers = { + week: weekView, + month: monthView, + quarter: quarterView, +}; + export const ChartViewRoot: FC = observer((props) => { const { border, title, blockIds, - getBlockById, loadMoreBlocks, loaderTitle, blockUpdateHandler, @@ -60,15 +70,23 @@ export const ChartViewRoot: FC = observer((props) => { showAllBlocks, quickAdd, showToday, + updateBlockDates, } = props; // states const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); // hooks - const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = - useGanttChart(); + const { + currentView, + currentViewData, + renderView, + updateCurrentView, + updateCurrentViewData, + updateRenderView, + updateAllBlocksOnChartChangeWhileDragging, + } = useTimeLineChartStore(); - const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => { + const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews, targetDate?: Date) => { const selectedCurrentView: TGanttViews = view; const selectedCurrentViewData: ChartDataType | undefined = selectedCurrentView && selectedCurrentView === currentViewData?.key @@ -77,21 +95,27 @@ export const ChartViewRoot: FC = observer((props) => { if (selectedCurrentViewData === undefined) return; - let currentRender: any; - if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side); + const currentViewHelpers = timelineViewHelpers[selectedCurrentView]; + const currentRender = currentViewHelpers.generateChart(selectedCurrentViewData, side, targetDate); + const mergeRenderPayloads = currentViewHelpers.mergeRenderPayloads as ( + a: IWeekBlock[] | IMonthView | IMonthBlock[], + b: IWeekBlock[] | IMonthView | IMonthBlock[] + ) => IWeekBlock[] | IMonthView | IMonthBlock[]; // updating the prevData, currentData and nextData - if (currentRender.payload.length > 0) { + if (currentRender.payload) { updateCurrentViewData(currentRender.state); if (side === "left") { updateCurrentView(selectedCurrentView); - updateRenderView([...currentRender.payload, ...renderView]); - updatingCurrentLeftScrollPosition(currentRender.scrollWidth); + updateRenderView(mergeRenderPayloads(currentRender.payload, renderView)); + updateItemsContainerWidth(currentRender.scrollWidth); + if (!targetDate) updateCurrentLeftScrollPosition(currentRender.scrollWidth); + updateAllBlocksOnChartChangeWhileDragging(currentRender.scrollWidth); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else if (side === "right") { updateCurrentView(view); - updateRenderView([...renderView, ...currentRender.payload]); + updateRenderView(mergeRenderPayloads(renderView, currentRender.payload)); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else { updateCurrentView(view); @@ -102,6 +126,8 @@ export const ChartViewRoot: FC = observer((props) => { }, 50); } } + + return currentRender.state; }; const handleToday = () => updateCurrentViewRenderPayload(null, currentView); @@ -112,12 +138,17 @@ export const ChartViewRoot: FC = observer((props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const updatingCurrentLeftScrollPosition = (width: number) => { + const updateItemsContainerWidth = (width: number) => { + const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; + if (!scrollContainer) return; + setItemsContainerWidth(width + scrollContainer?.scrollLeft); + }; + + const updateCurrentLeftScrollPosition = (width: number) => { const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; if (!scrollContainer) return; scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft; - setItemsContainerWidth(width + scrollContainer?.scrollLeft); }; const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => { @@ -127,24 +158,12 @@ export const ChartViewRoot: FC = observer((props) => { const clientVisibleWidth: number = scrollContainer?.clientWidth; let scrollWidth: number = 0; let daysDifference: number = 0; - - // if (currentView === "hours") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "day") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "week") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "bi_week") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - if (currentView === "month") - daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "quarter") - // daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date); - // if (currentView === "year") - // daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date); + daysDifference = getNumberOfDaysBetweenTwoDates(currentState.data.startDate, date); scrollWidth = - daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width) + SIDEBAR_WIDTH / 2; + Math.abs(daysDifference) * currentState.data.dayWidth - + (clientVisibleWidth / 2 - currentState.data.dayWidth) + + SIDEBAR_WIDTH / 2; scrollContainer.scrollLeft = scrollWidth; }; @@ -167,7 +186,6 @@ export const ChartViewRoot: FC = observer((props) => { /> = observer((props) => { title={title} updateCurrentViewRenderPayload={updateCurrentViewRenderPayload} quickAdd={quickAdd} + updateBlockDates={updateBlockDates} />
); diff --git a/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx b/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx new file mode 100644 index 000000000..e9896d87e --- /dev/null +++ b/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx @@ -0,0 +1,18 @@ +import { RefObject } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useAutoScroller } from "@/hooks/use-auto-scroller"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants"; + +type Props = { + ganttContainerRef: RefObject; +}; +export const TimelineDragHelper = observer((props: Props) => { + const { ganttContainerRef } = props; + const { isDragging } = useTimeLineChartStore(); + + useAutoScroller(ganttContainerRef, isDragging, SIDEBAR_WIDTH, HEADER_HEIGHT); + return <>; +}); diff --git a/web/core/components/gantt-chart/chart/views/bi-week.tsx b/web/core/components/gantt-chart/chart/views/bi-week.tsx deleted file mode 100644 index 38c4dd386..000000000 --- a/web/core/components/gantt-chart/chart/views/bi-week.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -// hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; - -export const BiWeekChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/day.tsx b/web/core/components/gantt-chart/chart/views/day.tsx deleted file mode 100644 index 165ba81ad..000000000 --- a/web/core/components/gantt-chart/chart/views/day.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -// hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; - -export const DayChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/hours.tsx b/web/core/components/gantt-chart/chart/views/hours.tsx deleted file mode 100644 index a56fde2ca..000000000 --- a/web/core/components/gantt-chart/chart/views/hours.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -// hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; - -export const HourChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/index.ts b/web/core/components/gantt-chart/chart/views/index.ts index 8936623c2..ea7c85e84 100644 --- a/web/core/components/gantt-chart/chart/views/index.ts +++ b/web/core/components/gantt-chart/chart/views/index.ts @@ -1,7 +1,3 @@ -export * from "./bi-week"; -export * from "./day"; -export * from "./hours"; export * from "./month"; export * from "./quarter"; export * from "./week"; -export * from "./year"; diff --git a/web/core/components/gantt-chart/chart/views/month.tsx b/web/core/components/gantt-chart/chart/views/month.tsx index b3a095778..bfe29b90d 100644 --- a/web/core/components/gantt-chart/chart/views/month.tsx +++ b/web/core/components/gantt-chart/chart/views/month.tsx @@ -1,75 +1,105 @@ import { FC } from "react"; import { observer } from "mobx-react"; -// hooks +// components import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "@/components/gantt-chart/constants"; -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; // helpers import { cn } from "@/helpers/common.helper"; +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // types -import { IMonthBlock } from "../../views"; -// constants +import { IMonthView } from "../../views"; +import { getNumberOfDaysBetweenTwoDates } from "../../views/helpers"; export const MonthChartView: FC = observer(() => { // chart hook - const { currentViewData, renderView } = useGanttChart(); - const monthBlocks: IMonthBlock[] = renderView; + const { currentViewData, renderView } = useTimeLineChartStore(); + const monthView: IMonthView = renderView; + + if (!monthView) return <>; + + const { months, weeks } = monthView; + + const monthsStartDate = new Date(months[0].year, months[0].month, 1); + const weeksStartDate = weeks[0].startDate; + + const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate); return ( -
- {monthBlocks?.map((block, rootIndex) => ( -
+
+ {currentViewData && ( +
+ {/** Header Div */}
-
-
- {block?.title} -
-
-
- {block?.children?.map((monthDay, index) => ( + {/** Main Month Title */} +
+ {months?.map((monthBlock) => (
-
- {monthDay.dayData.shortTitle[0]}{" "} - - {monthDay.day} - +
+ {monthBlock?.title} + {monthBlock.today && ( + + Current + + )}
))}
+ {/** Weeks Sub title */} +
+ {weeks?.map((weekBlock) => ( +
+
+ + {weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()} + +
+
{weekBlock.weekData.shortTitle}
+
+ ))} +
-
- {block?.children?.map((monthDay, index) => ( + {/** Week Columns */} +
+ {weeks?.map((weekBlock) => (
- {["sat", "sun"].includes(monthDay?.dayData?.shortTitle) && ( -
- )} -
+ key={`column-${weekBlock.startDate}-${weekBlock.endDate}`} + className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", { + "bg-custom-primary-100/20": weekBlock.today, + })} + style={{ width: `${currentViewData?.data.dayWidth * 7}px` }} + /> ))}
- ))} + )}
); }); diff --git a/web/core/components/gantt-chart/chart/views/quarter.tsx b/web/core/components/gantt-chart/chart/views/quarter.tsx index 6b013f02b..421d0bdfe 100644 --- a/web/core/components/gantt-chart/chart/views/quarter.tsx +++ b/web/core/components/gantt-chart/chart/views/quarter.tsx @@ -1,50 +1,93 @@ import { FC } from "react"; import { observer } from "mobx-react"; +// Plane +import { cn } from "@plane/editor"; // hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants"; +import { groupMonthsToQuarters, IMonthBlock, IQuarterMonthBlock } from "../../views"; export const QuarterChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); + const { currentViewData, renderView } = useTimeLineChartStore(); + const monthBlocks: IMonthBlock[] = renderView; + + const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks); return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} +
+ {currentViewData && + quarterBlocks?.map((quarterBlock, rootIndex) => ( +
+ {/** Header Div */} +
+ {/** Main Quarter Title */} +
+
+ {quarterBlock?.title} + {quarterBlock.today && ( + + Current + + )} +
+
+ {quarterBlock.shortTitle}
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
+ {quarterBlock?.children?.map((monthBlock, index) => ( +
+
+ -
{_item.title}
-
-
- {_item?.today &&
} -
+ {monthBlock.monthData.shortTitle} +
- ))} +
+ ))}
- ))} -
- + {/** Month Columns */} +
+ {quarterBlock?.children?.map((monthBlock, index) => ( +
+ ))} +
+
+ ))} +
); }); diff --git a/web/core/components/gantt-chart/chart/views/week.tsx b/web/core/components/gantt-chart/chart/views/week.tsx index 186377238..2096acbc2 100644 --- a/web/core/components/gantt-chart/chart/views/week.tsx +++ b/web/core/components/gantt-chart/chart/views/week.tsx @@ -1,54 +1,93 @@ import { FC } from "react"; import { observer } from "mobx-react"; +// Plane +import { cn } from "@plane/editor"; // hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants"; +import { IWeekBlock } from "../../views"; export const WeekChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); + const { currentViewData, renderView } = useTimeLineChartStore(); + const weekBlocks: IWeekBlock[] = renderView; return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} +
+ {currentViewData && + weekBlocks?.map((block, rootIndex) => ( +
+ {/** Header Div */} +
+ {/** Main Months Title */} +
+
+ {block?.title} +
+
+ {block?.weekData?.title}
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
+ {/** Days Sub title */} +
+ {block?.children?.map((weekDay, index) => ( +
+
+ {weekDay.dayData.abbreviation}
- ))} +
+ + {weekDay.date.getDate()} + +
+
+ ))}
- ))} -
- + {/** Day Columns */} +
+ {block?.children?.map((weekDay, index) => ( +
+ {["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && ( +
+ )} +
+ ))} +
+
+ ))} +
); }); diff --git a/web/core/components/gantt-chart/chart/views/year.tsx b/web/core/components/gantt-chart/chart/views/year.tsx deleted file mode 100644 index 1b6efaaed..000000000 --- a/web/core/components/gantt-chart/chart/views/year.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -// hooks -import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart"; - -export const YearChartView: FC = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/constants.ts b/web/core/components/gantt-chart/constants.ts index 52167a498..e875fb8ea 100644 --- a/web/core/components/gantt-chart/constants.ts +++ b/web/core/components/gantt-chart/constants.ts @@ -1,7 +1,11 @@ export const BLOCK_HEIGHT = 44; -export const HEADER_HEIGHT = 60; +export const HEADER_HEIGHT = 48; + +export const GANTT_BREADCRUMBS_HEIGHT = 40; export const SIDEBAR_WIDTH = 360; +export const DEFAULT_BLOCK_WIDTH = 60; + export const GANTT_SELECT_GROUP = "gantt-issues"; diff --git a/web/core/components/gantt-chart/contexts/index.tsx b/web/core/components/gantt-chart/contexts/index.tsx index 1b3f035ed..7a7c63f83 100644 --- a/web/core/components/gantt-chart/contexts/index.tsx +++ b/web/core/components/gantt-chart/contexts/index.tsx @@ -1,23 +1,15 @@ -import React, { FC, createContext } from "react"; -// mobx store -import { GanttStore } from "@/store/issue/issue_gantt_view.store"; +import { createContext, useContext } from "react"; -let ganttViewStore = new GanttStore(); +export enum ETimeLineTypeType { + ISSUE = "ISSUE", + MODULE = "MODULE", + PROJECT = "PROJECT", +} -export const GanttStoreContext = createContext(ganttViewStore); +export const TimeLineTypeContext = createContext(undefined); -const initializeStore = () => { - const newGanttViewStore = ganttViewStore ?? new GanttStore(); - if (typeof window === "undefined") return newGanttViewStore; - if (!ganttViewStore) ganttViewStore = newGanttViewStore; - return newGanttViewStore; -}; - -type GanttStoreProviderProps = { - children: React.ReactNode; -}; - -export const GanttStoreProvider: FC = ({ children }) => { - const store = initializeStore(); - return {children}; +export const useTimeLineType = () => { + const timelineType = useContext(TimeLineTypeContext); + + return timelineType; }; diff --git a/web/core/components/gantt-chart/data/index.ts b/web/core/components/gantt-chart/data/index.ts index cc15c5d9e..dc8840330 100644 --- a/web/core/components/gantt-chart/data/index.ts +++ b/web/core/components/gantt-chart/data/index.ts @@ -3,28 +3,35 @@ import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types"; // constants export const weeks: WeekMonthDataType[] = [ - { key: 0, shortTitle: "sun", title: "sunday" }, - { key: 1, shortTitle: "mon", title: "monday" }, - { key: 2, shortTitle: "tue", title: "tuesday" }, - { key: 3, shortTitle: "wed", title: "wednesday" }, - { key: 4, shortTitle: "thurs", title: "thursday" }, - { key: 5, shortTitle: "fri", title: "friday" }, - { key: 6, shortTitle: "sat", title: "saturday" }, + { key: 0, shortTitle: "sun", title: "sunday", abbreviation: "Su" }, + { key: 1, shortTitle: "mon", title: "monday", abbreviation: "M" }, + { key: 2, shortTitle: "tue", title: "tuesday", abbreviation: "T" }, + { key: 3, shortTitle: "wed", title: "wednesday", abbreviation: "W" }, + { key: 4, shortTitle: "thurs", title: "thursday", abbreviation: "Th" }, + { key: 5, shortTitle: "fri", title: "friday", abbreviation: "F" }, + { key: 6, shortTitle: "sat", title: "saturday", abbreviation: "Sa" }, ]; export const months: WeekMonthDataType[] = [ - { key: 0, shortTitle: "jan", title: "january" }, - { key: 1, shortTitle: "feb", title: "february" }, - { key: 2, shortTitle: "mar", title: "march" }, - { key: 3, shortTitle: "apr", title: "april" }, - { key: 4, shortTitle: "may", title: "may" }, - { key: 5, shortTitle: "jun", title: "june" }, - { key: 6, shortTitle: "jul", title: "july" }, - { key: 7, shortTitle: "aug", title: "august" }, - { key: 8, shortTitle: "sept", title: "september" }, - { key: 9, shortTitle: "oct", title: "october" }, - { key: 10, shortTitle: "nov", title: "november" }, - { key: 11, shortTitle: "dec", title: "december" }, + { key: 0, shortTitle: "jan", title: "january", abbreviation: "Jan" }, + { key: 1, shortTitle: "feb", title: "february", abbreviation: "Feb" }, + { key: 2, shortTitle: "mar", title: "march", abbreviation: "Mar" }, + { key: 3, shortTitle: "apr", title: "april", abbreviation: "Apr" }, + { key: 4, shortTitle: "may", title: "may", abbreviation: "May" }, + { key: 5, shortTitle: "jun", title: "june", abbreviation: "Jun" }, + { key: 6, shortTitle: "jul", title: "july", abbreviation: "Jul" }, + { key: 7, shortTitle: "aug", title: "august", abbreviation: "Aug" }, + { key: 8, shortTitle: "sept", title: "september", abbreviation: "Sept" }, + { key: 9, shortTitle: "oct", title: "october", abbreviation: "Oct" }, + { key: 10, shortTitle: "nov", title: "november", abbreviation: "Nov" }, + { key: 11, shortTitle: "dec", title: "december", abbreviation: "Dec" }, +]; + +export const quarters: WeekMonthDataType[] = [ + { key: 0, shortTitle: "Q1", title: "Jan - Mar", abbreviation: "Q1" }, + { key: 1, shortTitle: "Q2", title: "Apr - Jun", abbreviation: "Q2" }, + { key: 2, shortTitle: "Q3", title: "Jul - Sept", abbreviation: "Q3" }, + { key: 3, shortTitle: "Q4", title: "Oct - Dec", abbreviation: "Q4" }, ]; export const charCapitalize = (word: string) => `${word.charAt(0).toUpperCase()}${word.substring(1)}`; @@ -54,50 +61,17 @@ export const datePreview = (date: Date, includeTime: boolean = false) => { // context data export const VIEWS_LIST: ChartDataType[] = [ - // { - // key: "hours", - // title: "Hours", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 4, - // width: 40, - // }, - // }, - // { - // key: "days", - // title: "Days", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 4, - // width: 40, - // }, - // }, - // { - // key: "week", - // title: "Week", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 4, - // width: 180, // it will preview week dates with weekends highlighted with 1 week limitations ex: title (Wed 1, Thu 2, Fri 3) - // }, - // }, - // { - // key: "bi_week", - // title: "Bi-Week", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 4, - // width: 100, // it will preview monthly all dates with weekends highlighted with 3 week limitations ex: title (Wed 1, Thu 2, Fri 3) - // }, - // }, + { + key: "week", + title: "Week", + data: { + startDate: new Date(), + currentDate: new Date(), + endDate: new Date(), + approxFilterRange: 4, // it will preview week dates with weekends highlighted with 1 week limitations ex: title (Wed 1, Thu 2, Fri 3) + dayWidth: 60, + }, + }, { key: "month", title: "Month", @@ -105,32 +79,21 @@ export const VIEWS_LIST: ChartDataType[] = [ startDate: new Date(), currentDate: new Date(), endDate: new Date(), - approxFilterRange: 6, - width: 55, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3) + approxFilterRange: 6, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3) + dayWidth: 20, + }, + }, + { + key: "quarter", + title: "Quarter", + data: { + startDate: new Date(), + currentDate: new Date(), + endDate: new Date(), + approxFilterRange: 24, // it will preview week starting dates all months data and there is 3 months limitation for preview ex: title (2, 9, 16, 23, 30) + dayWidth: 5, }, }, - // { - // key: "quarter", - // title: "Quarter", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 12, - // width: 100, // it will preview week starting dates all months data and there is 3 months limitation for preview ex: title (2, 9, 16, 23, 30) - // }, - // }, - // { - // key: "year", - // title: "Year", - // data: { - // startDate: new Date(), - // currentDate: new Date(), - // endDate: new Date(), - // approxFilterRange: 10, - // width: 80, // it will preview week starting dates all months data and there is no limitation for preview ex: title (2, 9, 16, 23, 30) - // }, - // }, ]; export const currentViewDataWithView = (view: TGanttViews = "month") => diff --git a/web/core/components/gantt-chart/helpers/add-block.tsx b/web/core/components/gantt-chart/helpers/add-block.tsx index 1ef0492f0..0c8ccdcb1 100644 --- a/web/core/components/gantt-chart/helpers/add-block.tsx +++ b/web/core/components/gantt-chart/helpers/add-block.tsx @@ -8,11 +8,11 @@ import { Plus } from "lucide-react"; import { Tooltip } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -// types -import { usePlatformOS } from "@/hooks/use-platform-os"; -import { useGanttChart } from "../hooks/use-gantt-chart"; -import { IBlockUpdateData, IGanttBlock } from "../types"; // hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { IBlockUpdateData, IGanttBlock } from "../types"; type Props = { block: IGanttBlock; @@ -30,16 +30,20 @@ export const ChartAddBlock: React.FC = observer((props) => { // hooks const { isMobile } = usePlatformOS(); // chart hook - const { currentViewData } = useGanttChart(); + const { currentViewData, currentView } = useTimeLineChartStore(); const handleButtonClick = () => { if (!currentViewData) return; - const { startDate: chartStartDate, width } = currentViewData.data; - const columnNumber = buttonXPosition / width; + const { startDate: chartStartDate, dayWidth } = currentViewData.data; + const columnNumber = buttonXPosition / dayWidth; + + let numberOfDays = 1; + + if (currentView === "quarter") numberOfDays = 7; const startDate = addDays(chartStartDate, columnNumber); - const endDate = addDays(startDate, 1); + const endDate = addDays(startDate, numberOfDays); blockUpdateHandler(block.data, { start_date: renderFormattedPayloadDate(startDate) ?? undefined, @@ -57,8 +61,8 @@ export const ChartAddBlock: React.FC = observer((props) => { setButtonXPosition(e.offsetX); - const { startDate: chartStartDate, width } = currentViewData.data; - const columnNumber = buttonXPosition / width; + const { startDate: chartStartDate, dayWidth } = currentViewData.data; + const columnNumber = buttonXPosition / dayWidth; const startDate = addDays(chartStartDate, columnNumber); setButtonStartDate(startDate); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx b/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx new file mode 100644 index 000000000..b5f0de5c1 --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// Plane +import { cn } from "@plane/editor"; +//helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +//hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; + +type LeftResizableProps = { + enableBlockLeftResize: boolean; + handleBlockDrag: (e: React.MouseEvent, dragDirection: "left" | "right" | "move") => void; + isMoving: "left" | "right" | "move" | undefined; + position?: { + marginLeft: number; + width: number; + }; +}; +export const LeftResizable = observer((props: LeftResizableProps) => { + const { enableBlockLeftResize, isMoving, handleBlockDrag, position } = props; + const [isHovering, setIsHovering] = useState(false); + + const { getDateFromPositionOnGantt } = useTimeLineChartStore(); + + const date = position ? getDateFromPositionOnGantt(position.marginLeft, 0) : undefined; + const dateString = date ? renderFormattedDate(date) : undefined; + + const isLeftResizing = isMoving === "left" || isMoving === "move"; + + if (!enableBlockLeftResize) return null; + + return ( + <> + {(isHovering || isLeftResizing) && dateString && ( +
+
{dateString}
+
+ )} +
{ + handleBlockDrag(e, "left"); + }} + onMouseOver={() => { + setIsHovering(true); + }} + onMouseOut={() => { + setIsHovering(false); + }} + className="absolute -left-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md" + /> +
+ + ); +}); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx b/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx new file mode 100644 index 000000000..bb262875b --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// Plane +import { cn } from "@plane/editor"; +//helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +//hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; + +type RightResizableProps = { + enableBlockRightResize: boolean; + handleBlockDrag: (e: React.MouseEvent, dragDirection: "left" | "right" | "move") => void; + isMoving: "left" | "right" | "move" | undefined; + position?: { + marginLeft: number; + width: number; + }; +}; +export const RightResizable = observer((props: RightResizableProps) => { + const { enableBlockRightResize, handleBlockDrag, isMoving, position } = props; + const [isHovering, setIsHovering] = useState(false); + + const { getDateFromPositionOnGantt } = useTimeLineChartStore(); + + const date = position ? getDateFromPositionOnGantt(position.marginLeft + position.width, -1) : undefined; + const dateString = date ? renderFormattedDate(date) : undefined; + + const isRightResizing = isMoving === "right" || isMoving === "move"; + + if (!enableBlockRightResize) return null; + + return ( + <> + {(isHovering || isRightResizing) && dateString && ( +
+
{dateString}
+
+ )} +
handleBlockDrag(e, "right")} + onMouseOver={() => { + setIsHovering(true); + }} + onMouseOut={() => { + setIsHovering(false); + }} + className="absolute -right-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md" + /> +
+ + ); +}); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts b/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts new file mode 100644 index 000000000..a8c93a804 --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts @@ -0,0 +1,127 @@ +import { useRef, useState } from "react"; +// Plane +import { setToast } from "@plane/ui"; +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; +// +import { SIDEBAR_WIDTH } from "../../constants"; +import { IBlockUpdateDependencyData, IGanttBlock } from "../../types"; + +export const useGanttResizable = ( + block: IGanttBlock, + resizableRef: React.RefObject, + ganttContainerRef: React.RefObject, + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise +) => { + // refs + const initialPositionRef = useRef<{ marginLeft: number; width: number; offsetX: number }>({ + marginLeft: 0, + width: 0, + offsetX: 0, + }); + const ganttContainerDimensions = useRef(); + const currMouseEvent = useRef(); + // states + const { currentViewData, updateBlockPosition, setIsDragging, getUpdatedPositionAfterDrag } = useTimeLineChartStore(); + const [isMoving, setIsMoving] = useState<"left" | "right" | "move" | undefined>(); + + // handle block resize from the left end + const handleBlockDrag = ( + e: React.MouseEvent, + dragDirection: "left" | "right" | "move" + ) => { + const ganttContainerElement = ganttContainerRef.current; + if (!currentViewData || !resizableRef.current || !block.position || !ganttContainerElement) return; + + if (e.button !== 0) return; + + const resizableDiv = resizableRef.current; + + ganttContainerDimensions.current = ganttContainerElement.getBoundingClientRect(); + + const dayWidth = currentViewData.data.dayWidth; + const mouseX = e.clientX - ganttContainerDimensions.current.left - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft; + + // record position on drag start + initialPositionRef.current = { + width: block.position.width ?? 0, + marginLeft: block.position.marginLeft ?? 0, + offsetX: mouseX - block.position.marginLeft, + }; + + const handleOnScroll = () => { + if (currMouseEvent.current) handleMouseMove(currMouseEvent.current); + }; + + const handleMouseMove = (e: MouseEvent) => { + currMouseEvent.current = e; + setIsMoving(dragDirection); + setIsDragging(true); + + if (!ganttContainerDimensions.current) return; + + const { left: containerLeft } = ganttContainerDimensions.current; + + const mouseX = e.clientX - containerLeft - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft; + + let width = initialPositionRef.current.width; + let marginLeft = initialPositionRef.current.marginLeft; + + if (dragDirection === "left") { + // calculate new marginLeft and update the initial marginLeft to the newly calculated one + marginLeft = Math.round(mouseX / dayWidth) * dayWidth; + // get Dimensions from dom's style + const prevMarginLeft = parseFloat(resizableDiv.style.marginLeft.slice(0, -2)); + const prevWidth = parseFloat(resizableDiv.style.width.slice(0, -2)); + // calculate new width + const marginDelta = prevMarginLeft - marginLeft; + width = prevWidth + marginDelta; + } else if (dragDirection === "right") { + // calculate new width and update the initialMarginLeft using += + width = Math.round(mouseX / dayWidth) * dayWidth - marginLeft; + } else if (dragDirection === "move") { + // calculate new marginLeft and update the initial marginLeft using -= + marginLeft = Math.round((mouseX - initialPositionRef.current.offsetX) / dayWidth) * dayWidth; + } + + // block needs to be at least 1 dayWidth Wide + if (width < dayWidth) return; + + resizableDiv.style.width = `${width}px`; + resizableDiv.style.marginLeft = `${marginLeft}px`; + + const deltaLeft = Math.round((marginLeft - (block.position?.marginLeft ?? 0)) / dayWidth) * dayWidth; + const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth; + + // call update blockPosition + if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth, dragDirection !== "move"); + }; + + // remove event listeners and call updateBlockDates + const handleMouseUp = () => { + setIsMoving(undefined); + + document.removeEventListener("mousemove", handleMouseMove); + ganttContainerElement.removeEventListener("scroll", handleOnScroll); + document.removeEventListener("mouseup", handleMouseUp); + + try { + const blockUpdates = getUpdatedPositionAfterDrag(block.id, dragDirection !== "move"); + updateBlockDates && updateBlockDates(blockUpdates); + } catch (e) { + setToast; + } + + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + ganttContainerElement.addEventListener("scroll", handleOnScroll); + document.addEventListener("mouseup", handleMouseUp); + }; + + return { + isMoving, + handleBlockDrag, + }; +}; diff --git a/web/core/components/gantt-chart/helpers/draggable.tsx b/web/core/components/gantt-chart/helpers/draggable.tsx index 7e3390ba8..a19b19f63 100644 --- a/web/core/components/gantt-chart/helpers/draggable.tsx +++ b/web/core/components/gantt-chart/helpers/draggable.tsx @@ -1,338 +1,64 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { RefObject } from "react"; import { observer } from "mobx-react"; -import { ArrowRight } from "lucide-react"; // hooks import { IGanttBlock } from "@/components/gantt-chart"; // helpers import { cn } from "@/helpers/common.helper"; -// constants -import { SIDEBAR_WIDTH } from "../constants"; -import { useGanttChart } from "../hooks/use-gantt-chart"; +// Plane-web +import { LeftDependencyDraggable, RightDependencyDraggable } from "@/plane-web/components/gantt-chart"; +// +import { LeftResizable } from "./blockResizables/left-resizable"; +import { RightResizable } from "./blockResizables/right-resizable"; type Props = { block: IGanttBlock; blockToRender: (data: any) => React.ReactNode; - handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void; + handleBlockDrag: (e: React.MouseEvent, dragDirection: "left" | "right" | "move") => void; + isMoving: "left" | "right" | "move" | undefined; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; - ganttContainerRef: React.RefObject; + ganttContainerRef: RefObject; }; export const ChartDraggable: React.FC = observer((props) => { const { block, blockToRender, - handleBlock, + handleBlockDrag, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, + isMoving, ganttContainerRef, } = props; - // states - const [isLeftResizing, setIsLeftResizing] = useState(false); - const [isRightResizing, setIsRightResizing] = useState(false); - const [isMoving, setIsMoving] = useState(false); - const [isHidden, setIsHidden] = useState(true); - const [scrollLeft, setScrollLeft] = useState(0); - // refs - const resizableRef = useRef(null); - // chart hook - const { currentViewData } = useGanttChart(); - // check if cursor reaches either end while resizing/dragging - const checkScrollEnd = (e: MouseEvent): number => { - const SCROLL_THRESHOLD = 70; - - let delWidth = 0; - - const ganttContainer = document.querySelector("#gantt-container") as HTMLDivElement; - const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLDivElement; - - if (!ganttContainer || !ganttSidebar) return 0; - - const posFromLeft = e.clientX; - // manually scroll to left if reached the left end while dragging - if (posFromLeft - (ganttContainer.getBoundingClientRect().left + ganttSidebar.clientWidth) <= SCROLL_THRESHOLD) { - if (e.movementX > 0) return 0; - - delWidth = -5; - - ganttContainer.scrollBy(delWidth, 0); - } else delWidth = e.movementX; - - // manually scroll to right if reached the right end while dragging - const posFromRight = ganttContainer.getBoundingClientRect().right - e.clientX; - if (posFromRight <= SCROLL_THRESHOLD) { - if (e.movementX < 0) return 0; - - delWidth = 5; - - ganttContainer.scrollBy(delWidth, 0); - } else delWidth = e.movementX; - - return delWidth; - }; - // handle block resize from the left end - const handleBlockLeftResize = (e: React.MouseEvent) => { - if (!currentViewData || !resizableRef.current || !block.position) return; - - if (e.button !== 0) return; - - const resizableDiv = resizableRef.current; - - const columnWidth = currentViewData.data.width; - - const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); - - let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); - let initialMarginLeft = parseInt(resizableDiv.style.marginLeft); - - const handleMouseMove = (e: MouseEvent) => { - let delWidth = 0; - - delWidth = checkScrollEnd(e); - - // calculate new width and update the initialMarginLeft using -= - const newWidth = Math.round((initialWidth -= delWidth) / columnWidth) * columnWidth; - // calculate new marginLeft and update the initial marginLeft to the newly calculated one - const newMarginLeft = initialMarginLeft - (newWidth - (block.position?.width ?? 0)); - initialMarginLeft = newMarginLeft; - - // block needs to be at least 1 column wide - if (newWidth < columnWidth) return; - - resizableDiv.style.width = `${newWidth}px`; - resizableDiv.style.marginLeft = `${newMarginLeft}px`; - - if (block.position) { - block.position.width = newWidth; - block.position.marginLeft = newMarginLeft; - } - }; - - // remove event listeners and call block handler with the updated start date - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth); - - handleBlock(totalBlockShifts, "left"); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - // handle block resize from the right end - const handleBlockRightResize = (e: React.MouseEvent) => { - if (!currentViewData || !resizableRef.current || !block.position) return; - - if (e.button !== 0) return; - - const resizableDiv = resizableRef.current; - - const columnWidth = currentViewData.data.width; - - const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); - - let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); - - const handleMouseMove = (e: MouseEvent) => { - let delWidth = 0; - - delWidth = checkScrollEnd(e); - - // calculate new width and update the initialMarginLeft using += - const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth; - - // block needs to be at least 1 column wide - if (newWidth < columnWidth) return; - - resizableDiv.style.width = `${Math.max(newWidth, 80)}px`; - if (block.position) block.position.width = Math.max(newWidth, 80); - }; - - // remove event listeners and call block handler with the updated target date - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth); - - handleBlock(totalBlockShifts, "right"); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - // handle block x-axis move - const handleBlockMove = (e: React.MouseEvent) => { - if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return; - - if (e.button !== 0) return; - - const resizableDiv = resizableRef.current; - - const columnWidth = currentViewData.data.width; - - const blockInitialMarginLeft = parseInt(resizableDiv.style.marginLeft); - - let initialMarginLeft = parseInt(resizableDiv.style.marginLeft); - - const handleMouseMove = (e: MouseEvent) => { - setIsMoving(true); - - let delWidth = 0; - - delWidth = checkScrollEnd(e); - - // calculate new marginLeft and update the initial marginLeft using -= - const newMarginLeft = Math.round((initialMarginLeft += delWidth) / columnWidth) * columnWidth; - - resizableDiv.style.marginLeft = `${newMarginLeft}px`; - - if (block.position) block.position.marginLeft = newMarginLeft; - }; - - // remove event listeners and call block handler with the updated dates - const handleMouseUp = () => { - setIsMoving(false); - - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - const totalBlockShifts = Math.ceil( - (parseInt(resizableDiv.style.marginLeft) - blockInitialMarginLeft) / columnWidth - ); - - handleBlock(totalBlockShifts, "move"); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - // scroll to a hidden block - const handleScrollToBlock = () => { - const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; - if (!scrollContainer || !block.position) return; - // update container's scroll position to the block's position - scrollContainer.scrollLeft = block.position.marginLeft - 4; - }; - // check if block is hidden on either side - const isBlockHiddenOnLeft = - block.position?.marginLeft && - block.position?.width && - scrollLeft > block.position.marginLeft + block.position.width; - - useEffect(() => { - const ganttContainer = ganttContainerRef.current; - if (!ganttContainer) return; - - const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft); - ganttContainer.addEventListener("scroll", handleScroll); - return () => { - ganttContainer.removeEventListener("scroll", handleScroll); - }; - }, [ganttContainerRef]); - - useEffect(() => { - const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; - const resizableBlock = resizableRef.current; - if (!resizableBlock || !intersectionRoot) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - setIsHidden(!entry.isIntersecting); - }); - }, - { - root: intersectionRoot, - rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`, - } - ); - - observer.observe(resizableBlock); - - return () => { - observer.unobserve(resizableBlock); - }; - }, []); return ( - <> - {/* move to the hidden block */} - {isHidden && ( - - )} +
+ {/* left resize drag handle */} + +
enableBlockMove && handleBlockDrag(e, "move")} > - {/* left resize drag handle */} - {enableBlockLeftResize && ( - <> -
setIsLeftResizing(true)} - onMouseLeave={() => setIsLeftResizing(false)} - className="absolute -left-2.5 top-1/2 -translate-y-1/2 z-[3] h-full w-6 cursor-col-resize rounded-md" - /> -
- - )} -
- {blockToRender(block.data)} -
- {/* right resize drag handle */} - {enableBlockRightResize && ( - <> -
setIsRightResizing(true)} - onMouseLeave={() => setIsRightResizing(false)} - className="absolute -right-2.5 top-1/2 -translate-y-1/2 z-[2] h-full w-6 cursor-col-resize rounded-md" - /> -
- - )} + {blockToRender(block.data)}
- + {/* right resize drag handle */} + + +
); }); diff --git a/web/core/components/gantt-chart/hooks/index.ts b/web/core/components/gantt-chart/hooks/index.ts deleted file mode 100644 index 009650675..000000000 --- a/web/core/components/gantt-chart/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-gantt-chart"; diff --git a/web/core/components/gantt-chart/hooks/use-gantt-chart.ts b/web/core/components/gantt-chart/hooks/use-gantt-chart.ts deleted file mode 100644 index 916b38adc..000000000 --- a/web/core/components/gantt-chart/hooks/use-gantt-chart.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -// mobx store -import { GanttStoreContext } from "@/components/gantt-chart/contexts"; -// types -import { IGanttStore } from "@/store/issue/issue_gantt_view.store"; - -export const useGanttChart = (): IGanttStore => { - const context = useContext(GanttStoreContext); - if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider"); - return context; -}; diff --git a/web/core/components/gantt-chart/index.ts b/web/core/components/gantt-chart/index.ts index 78297ffcd..bb2cbc99c 100644 --- a/web/core/components/gantt-chart/index.ts +++ b/web/core/components/gantt-chart/index.ts @@ -1,7 +1,6 @@ export * from "./blocks"; export * from "./chart"; export * from "./helpers"; -export * from "./hooks"; export * from "./root"; export * from "./sidebar"; export * from "./types"; diff --git a/web/core/components/gantt-chart/root.tsx b/web/core/components/gantt-chart/root.tsx index ba879cefa..81f064e2f 100644 --- a/web/core/components/gantt-chart/root.tsx +++ b/web/core/components/gantt-chart/root.tsx @@ -1,8 +1,9 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; +import { observer } from "mobx-react"; // components -import { ChartDataType, ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; -// context -import { GanttStoreProvider } from "@/components/gantt-chart/contexts"; +import { ChartViewRoot, IBlockUpdateData, IBlockUpdateDependencyData } from "@/components/gantt-chart"; +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; type GanttChartRootProps = { border?: boolean; @@ -13,9 +14,9 @@ type GanttChartRootProps = { blockToRender: (data: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode; quickAdd?: React.JSX.Element | undefined; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; canLoadMoreBlocks?: boolean; loadMoreBlocks?: () => void; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; enableBlockLeftResize?: boolean | ((blockId: string) => boolean); enableBlockRightResize?: boolean | ((blockId: string) => boolean); enableBlockMove?: boolean | ((blockId: string) => boolean); @@ -27,7 +28,7 @@ type GanttChartRootProps = { showToday?: boolean; }; -export const GanttChartRoot: FC = (props) => { +export const GanttChartRoot: FC = observer((props) => { const { border = true, title, @@ -36,7 +37,6 @@ export const GanttChartRoot: FC = (props) => { blockUpdateHandler, sidebarToRender, blockToRender, - getBlockById, loadMoreBlocks, canLoadMoreBlocks, enableBlockLeftResize = false, @@ -49,32 +49,38 @@ export const GanttChartRoot: FC = (props) => { showAllBlocks = false, showToday = true, quickAdd, + updateBlockDates, } = props; + const { setBlockIds } = useTimeLineChartStore(); + + // update the timeline store with updated blockIds + useEffect(() => { + setBlockIds(blockIds); + }, [blockIds]); + return ( - - - + ); -}; +}); diff --git a/web/core/components/gantt-chart/sidebar/cycles/block.tsx b/web/core/components/gantt-chart/sidebar/cycles/block.tsx deleted file mode 100644 index 814e3aa99..000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/block.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { observer } from "mobx-react"; -// hooks -import { CycleGanttSidebarBlock } from "@/components/cycles"; -import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; -import { useGanttChart } from "@/components/gantt-chart/hooks"; -// components -// helpers -import { IGanttBlock } from "@/components/gantt-chart/types"; -import { cn } from "@/helpers/common.helper"; -import { findTotalDaysInRange } from "@/helpers/date-time.helper"; -// types -// constants - -type Props = { - block: IGanttBlock; - isDragging: boolean; -}; - -export const CyclesSidebarBlock: React.FC = observer((props) => { - const { block, isDragging } = props; - // store hooks - const { updateActiveBlockId, isBlockActive } = useGanttChart(); - - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( -
updateActiveBlockId(block.id)} - onMouseLeave={() => updateActiveBlockId(null)} - > -
-
-
- -
- {duration && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- ); -}); diff --git a/web/core/components/gantt-chart/sidebar/cycles/index.ts b/web/core/components/gantt-chart/sidebar/cycles/index.ts deleted file mode 100644 index 01acaeffb..000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sidebar"; diff --git a/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx b/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx deleted file mode 100644 index c5a0a28ff..000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -// ui -import { Loader } from "@plane/ui"; -// components -import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart/types"; -import { GanttDnDHOC } from "../gantt-dnd-HOC"; -import { handleOrderChange } from "../utils"; -import { CyclesSidebarBlock } from "./block"; -// types - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; - blockIds: string[]; - enableReorder: boolean; -}; - -export const CycleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props; - - const handleOnDrop = ( - draggingBlockId: string | undefined, - droppedBlockId: string | undefined, - dropAtEndOfList: boolean - ) => { - handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler); - }; - - return ( -
- {blockIds ? ( - blockIds.map((blockId, index) => { - const block = getBlockById(blockId); - if (!block.start_date || !block.target_date) return null; - return ( - - {(isDragging: boolean) => } - - ); - }) - ) : ( - - - - - - - )} -
- ); -}; diff --git a/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx b/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx index 79506ef4e..829feb1f9 100644 --- a/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx +++ b/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx @@ -34,7 +34,7 @@ export const GanttDnDHOC = observer((props: Props) => { draggable({ element, canDrag: () => isDragEnabled, - getInitialData: () => ({ id }), + getInitialData: () => ({ id, dragInstanceId: "GANTT_REORDER" }), onDragStart: () => { setIsDragging(true); }, @@ -44,7 +44,7 @@ export const GanttDnDHOC = observer((props: Props) => { }), dropTargetForElements({ element, - canDrop: ({ source }) => source?.data?.id !== id, + canDrop: ({ source }) => source?.data?.id !== id && source?.data?.dragInstanceId === "GANTT_REORDER", getData: ({ input, element }) => { const data = { id }; diff --git a/web/core/components/gantt-chart/sidebar/index.ts b/web/core/components/gantt-chart/sidebar/index.ts index ef9bfb5cb..e0e48c81e 100644 --- a/web/core/components/gantt-chart/sidebar/index.ts +++ b/web/core/components/gantt-chart/sidebar/index.ts @@ -1,4 +1,3 @@ -export * from "./cycles"; export * from "./issues"; export * from "./modules"; export * from "./root"; diff --git a/web/core/components/gantt-chart/sidebar/issues/block.tsx b/web/core/components/gantt-chart/sidebar/issues/block.tsx index 93b1f7962..bb286c280 100644 --- a/web/core/components/gantt-chart/sidebar/issues/block.tsx +++ b/web/core/components/gantt-chart/sidebar/issues/block.tsx @@ -2,14 +2,13 @@ import { observer } from "mobx-react"; // components import { Row } from "@plane/ui"; import { MultipleSelectEntityAction } from "@/components/core"; -import { useGanttChart } from "@/components/gantt-chart/hooks"; import { IssueGanttSidebarBlock } from "@/components/issues"; // helpers import { cn } from "@/helpers/common.helper"; -import { findTotalDaysInRange } from "@/helpers/date-time.helper"; // hooks import { useIssueDetail } from "@/hooks/store"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // constants import { BLOCK_HEIGHT, GANTT_SELECT_GROUP } from "../../constants"; // types @@ -25,12 +24,13 @@ type Props = { export const IssuesSidebarBlock = observer((props: Props) => { const { block, enableSelection, isDragging, selectionHelpers } = props; // store hooks - const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore(); const { getIsIssuePeeked } = useIssueDetail(); - const duration = findTotalDaysInRange(block.start_date, block.target_date); + const isBlockComplete = !!block?.start_date && !!block?.target_date; + const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined; - if (!block.data) return null; + if (!block?.data) return null; const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id); const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id); diff --git a/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx index b0be6b835..b2f6b8792 100644 --- a/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx +++ b/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -5,19 +5,22 @@ import { observer } from "mobx-react"; // ui import { Loader } from "@plane/ui"; // components -import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types"; +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +import { IBlockUpdateData } from "@/components/gantt-chart/types"; +import { GanttLayoutLIstItem } from "@/components/ui"; //hooks import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; +// +import { useTimeLineChart } from "../../../../hooks/use-timeline-chart"; +import { ETimeLineTypeType } from "../../contexts"; import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { handleOrderChange } from "../utils"; -// types import { IssuesSidebarBlock } from "./block"; type Props = { blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - getBlockById: (id: string) => IGanttBlock; canLoadMoreBlocks?: boolean; loadMoreBlocks?: () => void; ganttContainerRef: RefObject; @@ -32,7 +35,6 @@ export const IssueGanttSidebar: React.FC = observer((props) => { const { blockUpdateHandler, blockIds, - getBlockById, enableReorder, enableSelection, loadMoreBlocks, @@ -42,6 +44,8 @@ export const IssueGanttSidebar: React.FC = observer((props) => { selectionHelpers, } = props; + const { getBlockById } = useTimeLineChart(ETimeLineTypeType.ISSUE); + const { issues: { getIssueLoader }, } = useIssuesStore(); @@ -77,22 +81,30 @@ export const IssueGanttSidebar: React.FC = observer((props) => { if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return; return ( - } > - {(isDragging: boolean) => ( - - )} - + + {(isDragging: boolean) => ( + + )} + + ); })} {canLoadMoreBlocks && ( diff --git a/web/core/components/gantt-chart/sidebar/modules/block.tsx b/web/core/components/gantt-chart/sidebar/modules/block.tsx index e5a1603ef..fb8eaf481 100644 --- a/web/core/components/gantt-chart/sidebar/modules/block.tsx +++ b/web/core/components/gantt-chart/sidebar/modules/block.tsx @@ -1,28 +1,29 @@ import { observer } from "mobx-react"; -// hooks +// Plane import { Row } from "@plane/ui"; -import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; -import { useGanttChart } from "@/components/gantt-chart/hooks"; // components -import { IGanttBlock } from "@/components/gantt-chart/types"; +import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; import { ModuleGanttSidebarBlock } from "@/components/modules"; // helpers import { cn } from "@/helpers/common.helper"; -import { findTotalDaysInRange } from "@/helpers/date-time.helper"; -// types -// constants +// hooks +import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; type Props = { - block: IGanttBlock; + blockId: string; isDragging: boolean; }; export const ModulesSidebarBlock: React.FC = observer((props) => { - const { block, isDragging } = props; + const { blockId, isDragging } = props; // store hooks - const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { getBlockById, updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore(); + const block = getBlockById(blockId); - const duration = findTotalDaysInRange(block.start_date, block.target_date); + if (!block) return <>; + + const isBlockComplete = !!block.start_date && !!block.target_date; + const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined; return (
= (props) => { return (
{blockIds ? ( - blockIds.map((blockId, index) => { - const block = getBlockById(blockId); - return ( - - {(isDragging: boolean) => } - - ); - }) + blockIds.map((blockId, index) => ( + + {(isDragging: boolean) => } + + )) ) : ( diff --git a/web/core/components/gantt-chart/sidebar/root.tsx b/web/core/components/gantt-chart/sidebar/root.tsx index 70e5e152f..31c9137cc 100644 --- a/web/core/components/gantt-chart/sidebar/root.tsx +++ b/web/core/components/gantt-chart/sidebar/root.tsx @@ -21,7 +21,6 @@ type Props = { enableSelection: boolean | ((blockId: string) => boolean); sidebarToRender: (props: any) => React.ReactNode; title: string; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; quickAdd?: React.JSX.Element | undefined; selectionHelpers: TSelectionHelper; }; @@ -33,7 +32,6 @@ export const GanttChartSidebar: React.FC = observer((props) => { enableReorder, enableSelection, sidebarToRender, - getBlockById, loadMoreBlocks, canLoadMoreBlocks, ganttContainerRef, @@ -86,7 +84,6 @@ export const GanttChartSidebar: React.FC = observer((props) => { title, blockUpdateHandler, blockIds, - getBlockById, enableReorder, enableSelection, canLoadMoreBlocks, diff --git a/web/core/components/gantt-chart/sidebar/utils.ts b/web/core/components/gantt-chart/sidebar/utils.ts index 58e8e05de..15b8e55fc 100644 --- a/web/core/components/gantt-chart/sidebar/utils.ts +++ b/web/core/components/gantt-chart/sidebar/utils.ts @@ -16,17 +16,17 @@ export const handleOrderChange = ( // return if dropped outside the list if (sourceBlockIndex === -1 || destinationBlockIndex === -1 || sourceBlockIndex === destinationBlockIndex) return; - let updatedSortOrder = getBlockById(blockIds[sourceBlockIndex])?.sort_order; + let updatedSortOrder = getBlockById(blockIds[sourceBlockIndex])?.sort_order ?? 0; // update the sort order to the lowest if dropped at the top - if (destinationBlockIndex === 0) updatedSortOrder = getBlockById(blockIds[0])?.sort_order - 1000; + if (destinationBlockIndex === 0) updatedSortOrder = (getBlockById(blockIds[0])?.sort_order ?? 0) - 1000; // update the sort order to the highest if dropped at the bottom else if (destinationBlockIndex === blockIds.length) - updatedSortOrder = getBlockById(blockIds[blockIds.length - 1])?.sort_order + 1000; + updatedSortOrder = (getBlockById(blockIds[blockIds.length - 1])?.sort_order ?? 0) + 1000; // update the sort order to the average of the two adjacent blocks if dropped in between else { - const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order; - const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order; + const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order ?? 0; + const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order ?? 0; updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; } diff --git a/web/core/components/gantt-chart/types/index.ts b/web/core/components/gantt-chart/types/index.ts index cd90758fc..83b64a2b5 100644 --- a/web/core/components/gantt-chart/types/index.ts +++ b/web/core/components/gantt-chart/types/index.ts @@ -1,13 +1,14 @@ export interface IGanttBlock { data: any; id: string; + name: string; position?: { marginLeft: number; width: number; }; - sort_order: number; - start_date: Date | undefined; - target_date: Date | undefined; + sort_order: number | undefined; + start_date: string | undefined; + target_date: string | undefined; } export interface IBlockUpdateData { @@ -20,13 +21,20 @@ export interface IBlockUpdateData { target_date?: string; } -export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; +export interface IBlockUpdateDependencyData { + id: string; + start_date?: string; + target_date?: string; +} + +export type TGanttViews = "week" | "month" | "quarter"; // chart render types export interface WeekMonthDataType { key: number; shortTitle: string; title: string; + abbreviation: string; } export interface ChartDataType { @@ -40,5 +48,5 @@ export interface ChartDataTypeData { currentDate: Date; endDate: Date; approxFilterRange: number; - width: number; + dayWidth: number; } diff --git a/web/core/components/gantt-chart/views/bi-week-view.ts b/web/core/components/gantt-chart/views/bi-week-view.ts deleted file mode 100644 index 6ace4bcc4..000000000 --- a/web/core/components/gantt-chart/views/bi-week-view.ts +++ /dev/null @@ -1,132 +0,0 @@ -// types -import { weeks, months } from "../data"; -import { ChartDataType } from "../types"; -// data -// helpers -import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; - -type GetAllDaysInMonthInMonthViewType = { - date: any; - day: any; - dayData: any; - weekNumber: number; - title: string; - active: boolean; - today: boolean; -}; -const getAllDaysInMonthInMonthView = (month: number, year: number) => { - const day: GetAllDaysInMonthInMonthViewType[] = []; - const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); - const currentDate = new Date(); - - Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => { - const date: Date = generateDate(_day + 1, month, year); - day.push({ - date: date, - day: _day + 1, - dayData: weeks[date.getDay()], - weekNumber: getWeekNumberByDate(date), - title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, - active: false, - today: - currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 - ? true - : false, - }); - }); - - return day; -}; - -const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - - const monthPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: getAllDaysInMonthInMonthView(currentMonth, currentYear), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return monthPayload; -}; - -export const generateBiWeekChart = (monthPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = monthPayload; - const renderPayload: any = []; - - const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; - let minusDate: Date = new Date(); - let plusDate: Date = new Date(); - - if (side === null) { - const currentDate = renderState.data.currentDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { - ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], - }, - }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, - }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, - }; - } - - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear)); - } - - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; -}; - -export const getNumberOfDaysBetweenTwoDatesInBiWeek = (startDate: Date, endDate: Date) => { - let daysDifference: number = 0; - startDate.setHours(0, 0, 0, 0); - endDate.setHours(0, 0, 0, 0); - - const timeDifference: number = startDate.getTime() - endDate.getTime(); - daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24))); - - return daysDifference; -}; diff --git a/web/core/components/gantt-chart/views/day-view.ts b/web/core/components/gantt-chart/views/day-view.ts deleted file mode 100644 index e8da6801c..000000000 --- a/web/core/components/gantt-chart/views/day-view.ts +++ /dev/null @@ -1,162 +0,0 @@ -// types -import { weeks, months } from "../data"; -import { ChartDataType } from "../types"; -// data - -export const getWeekNumberByDate = (date: Date) => { - const firstDayOfYear = new Date(date.getFullYear(), 0, 1); - const daysOffset = firstDayOfYear.getDay(); - - const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000; - const weekStart = new Date(firstWeekStart); - - const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; - - return weekNumber; -}; - -export const getNumberOfDaysInMonth = (month: number, year: number) => { - const date = new Date(year, month, 1); - - date.setMonth(date.getMonth() + 1); - date.setDate(date.getDate() - 1); - - return date.getDate(); -}; - -export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); - -export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { - const months = []; - - const startYear = startDate.getFullYear(); - const startMonth = startDate.getMonth(); - - const endYear = endDate.getFullYear(); - const endMonth = endDate.getMonth(); - - const currentDate = new Date(startYear, startMonth); - - while (currentDate <= endDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - months.push(new Date(currentYear, currentMonth)); - currentDate.setMonth(currentDate.getMonth() + 1); - } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate); - - return months; -}; - -export type GetAllDaysInMonthType = { - date: any; - day: any; - dayData: any; - weekNumber: number; - title: string; - today: boolean; -}; -export const getAllDaysInMonth = (month: number, year: number) => { - const day: GetAllDaysInMonthType[] = []; - const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); - const currentDate = new Date(); - - Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => { - const date: Date = generateDate(_day + 1, month, year); - day.push({ - date: date, - day: _day + 1, - dayData: weeks[date.getDay()], - weekNumber: getWeekNumberByDate(date), - title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, - today: - currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 - ? true - : false, - }); - }); - - return day; -}; - -export const generateMonthDataByMonth = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - - const monthPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: getAllDaysInMonth(currentMonth, currentYear), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return monthPayload; -}; - -export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = monthPayload; - const renderPayload: any = []; - - const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; - let minusDate: Date = new Date(); - let plusDate: Date = new Date(); - - if (side === null) { - const currentDate = renderState.data.currentDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { - ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], - }, - }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, - }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, - }; - } - - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonth(currentMonth, currentYear)); - } - - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; -}; diff --git a/web/core/components/gantt-chart/views/helpers.ts b/web/core/components/gantt-chart/views/helpers.ts index 4bd295ce3..54b1a43be 100644 --- a/web/core/components/gantt-chart/views/helpers.ts +++ b/web/core/components/gantt-chart/views/helpers.ts @@ -1,17 +1,33 @@ -// Generating the date by using the year, month, and day +import { addDaysToDate, findTotalDaysInRange, getDate } from "@/helpers/date-time.helper"; +import { DEFAULT_BLOCK_WIDTH } from "../constants"; +import { ChartDataType, IGanttBlock } from "../types"; + +/** + * Generates Date by using Day, month and Year + * @param day + * @param month + * @param year + * @returns + */ export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); -// Getting the number of days in a month +/** + * Returns number of days in month + * @param month + * @param year + * @returns + */ export const getNumberOfDaysInMonth = (month: number, year: number) => { - const date = new Date(year, month, 1); - - date.setMonth(date.getMonth() + 1); - date.setDate(date.getDate() - 1); + const date = new Date(year, month + 1, 0); return date.getDate(); }; -// Getting the week number by date +/** + * Returns week number from date + * @param date + * @returns + */ export const getWeekNumberByDate = (date: Date) => { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); const daysOffset = firstDayOfYear.getDay(); @@ -24,67 +40,92 @@ export const getWeekNumberByDate = (date: Date) => { return weekNumber; }; -// Getting all weeks between two dates -export const getWeeksByMonthAndYear = (month: number, year: number) => { - const weeks = []; - const startDate = new Date(year, month, 1); - const endDate = new Date(year, month + 1, 0); - const currentDate = new Date(startDate.getTime()); +/** + * Returns number of days between two dates + * @param startDate + * @param endDate + * @returns + */ +export const getNumberOfDaysBetweenTwoDates = (startDate: Date, endDate: Date) => { + let daysDifference: number = 0; + startDate.setHours(0, 0, 0, 0); + endDate.setHours(0, 0, 0, 0); - currentDate.setDate(currentDate.getDate() + ((7 - currentDate.getDay()) % 7)); + const timeDifference: number = startDate.getTime() - endDate.getTime(); + daysDifference = Math.round(timeDifference / (1000 * 60 * 60 * 24)); - while (currentDate <= endDate) { - weeks.push({ - year: year, - month: month, - weekNumber: getWeekNumberByDate(currentDate), - startDate: new Date(currentDate.getTime()), - endDate: new Date(currentDate.getTime() + 6 * 24 * 60 * 60 * 1000), - }); - currentDate.setDate(currentDate.getDate() + 7); - } - - return weeks; + return daysDifference; }; -// Getting all dates in a week by week number and year -export const getAllDatesInWeekByWeekNumber = (weekNumber: number, year: number) => { - const januaryFirst = new Date(year, 0, 1); - const firstDayOfYear = - januaryFirst.getDay() === 0 ? januaryFirst : new Date(year, 0, 1 + (7 - januaryFirst.getDay())); +/** + * returns a date corresponding to the position on the timeline chart + * @param position + * @param chartData + * @param offsetDays + * @returns + */ +export const getDateFromPositionOnGantt = (position: number, chartData: ChartDataType, offsetDays = 0) => { + const numberOfDaysSinceStart = Math.round(position / chartData.data.dayWidth) + offsetDays; - const startDate = new Date(firstDayOfYear.getTime()); - startDate.setDate(startDate.getDate() + 7 * (weekNumber - 1)); + const newDate = addDaysToDate(chartData.data.startDate, numberOfDaysSinceStart); - const datesInWeek = []; - for (let i = 0; i < 7; i++) { - const currentDate = new Date(startDate.getTime()); - currentDate.setDate(currentDate.getDate() + i); - datesInWeek.push(currentDate); - } + if (!newDate) undefined; - return datesInWeek; + return newDate; }; -// Getting the dates between two dates -export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { - const dates = []; +/** + * returns the position and width of the block on the timeline chart from startDate and EndDate + * @param chartData + * @param itemData + * @returns + */ +export const getItemPositionWidth = (chartData: ChartDataType, itemData: IGanttBlock) => { + let scrollPosition: number = 0; + let scrollWidth: number = DEFAULT_BLOCK_WIDTH; - const startYear = startDate.getFullYear(); - const startMonth = startDate.getMonth(); + const { startDate: chartStartDate } = chartData.data; + const { start_date, target_date } = itemData; - const endYear = endDate.getFullYear(); - const endMonth = endDate.getMonth(); + const itemStartDate = getDate(start_date); + const itemTargetDate = getDate(target_date); - const currentDate = new Date(startYear, startMonth); + chartStartDate.setHours(0, 0, 0, 0); + itemStartDate?.setHours(0, 0, 0, 0); + itemTargetDate?.setHours(0, 0, 0, 0); - while (currentDate <= endDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - dates.push(new Date(currentYear, currentMonth)); - currentDate.setMonth(currentDate.getMonth() + 1); + if (!itemStartDate && !itemTargetDate) return; + + // get scroll position from the number of days and width of each day + scrollPosition = itemStartDate + ? getPositionFromDate(chartData, itemStartDate, 0) + : getPositionFromDate(chartData, itemTargetDate!, -1 * DEFAULT_BLOCK_WIDTH); + + if (itemStartDate && itemTargetDate) { + // get width of block + const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime(); + const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24))); + scrollWidth = (widthDaysDifference + 1) * chartData.data.dayWidth; } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) dates.push(endDate); - return dates; + return { marginLeft: scrollPosition, width: scrollWidth }; +}; + +export const getPositionFromDate = (chartData: ChartDataType, date: string | Date, offsetWidth: number) => { + const currDate = getDate(date); + + const { startDate: chartStartDate } = chartData.data; + + if (!currDate || !chartStartDate) return 0; + + chartStartDate.setHours(0, 0, 0, 0); + currDate.setHours(0, 0, 0, 0); + + // get number of days from chart start date to block's start date + const positionDaysDifference = Math.round(findTotalDaysInRange(chartStartDate, currDate, false) ?? 0); + + if (!positionDaysDifference) return 0; + + // get scroll position from the number of days and width of each day + return positionDaysDifference * chartData.data.dayWidth + offsetWidth; }; diff --git a/web/core/components/gantt-chart/views/hours-view.ts b/web/core/components/gantt-chart/views/hours-view.ts deleted file mode 100644 index e8da6801c..000000000 --- a/web/core/components/gantt-chart/views/hours-view.ts +++ /dev/null @@ -1,162 +0,0 @@ -// types -import { weeks, months } from "../data"; -import { ChartDataType } from "../types"; -// data - -export const getWeekNumberByDate = (date: Date) => { - const firstDayOfYear = new Date(date.getFullYear(), 0, 1); - const daysOffset = firstDayOfYear.getDay(); - - const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000; - const weekStart = new Date(firstWeekStart); - - const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; - - return weekNumber; -}; - -export const getNumberOfDaysInMonth = (month: number, year: number) => { - const date = new Date(year, month, 1); - - date.setMonth(date.getMonth() + 1); - date.setDate(date.getDate() - 1); - - return date.getDate(); -}; - -export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); - -export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { - const months = []; - - const startYear = startDate.getFullYear(); - const startMonth = startDate.getMonth(); - - const endYear = endDate.getFullYear(); - const endMonth = endDate.getMonth(); - - const currentDate = new Date(startYear, startMonth); - - while (currentDate <= endDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - months.push(new Date(currentYear, currentMonth)); - currentDate.setMonth(currentDate.getMonth() + 1); - } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate); - - return months; -}; - -export type GetAllDaysInMonthType = { - date: any; - day: any; - dayData: any; - weekNumber: number; - title: string; - today: boolean; -}; -export const getAllDaysInMonth = (month: number, year: number) => { - const day: GetAllDaysInMonthType[] = []; - const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); - const currentDate = new Date(); - - Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => { - const date: Date = generateDate(_day + 1, month, year); - day.push({ - date: date, - day: _day + 1, - dayData: weeks[date.getDay()], - weekNumber: getWeekNumberByDate(date), - title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, - today: - currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 - ? true - : false, - }); - }); - - return day; -}; - -export const generateMonthDataByMonth = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - - const monthPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: getAllDaysInMonth(currentMonth, currentYear), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return monthPayload; -}; - -export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = monthPayload; - const renderPayload: any = []; - - const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; - let minusDate: Date = new Date(); - let plusDate: Date = new Date(); - - if (side === null) { - const currentDate = renderState.data.currentDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { - ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], - }, - }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, - }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, - }; - } - - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonth(currentMonth, currentYear)); - } - - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; -}; diff --git a/web/core/components/gantt-chart/views/index.ts b/web/core/components/gantt-chart/views/index.ts index 8d4cb9be6..8a4835739 100644 --- a/web/core/components/gantt-chart/views/index.ts +++ b/web/core/components/gantt-chart/views/index.ts @@ -1,7 +1,4 @@ -// export * from "./hours-view"; -// export * from "./day-view"; export * from "./week-view"; -export * from "./bi-week-view"; export * from "./month-view"; -export * from "./quater-view"; -export * from "./year-view"; +export * from "./quarter-view"; +export * from "./helpers"; diff --git a/web/core/components/gantt-chart/views/month-view.ts b/web/core/components/gantt-chart/views/month-view.ts index 8bb635324..41acf14d5 100644 --- a/web/core/components/gantt-chart/views/month-view.ts +++ b/web/core/components/gantt-chart/views/month-view.ts @@ -1,38 +1,15 @@ -// types -import { findTotalDaysInRange } from "@/helpers/date-time.helper"; -import { weeks, months } from "../data"; -import { ChartDataType, IGanttBlock } from "../types"; -// data -// helpers -import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; - -type GetAllDaysInMonthInMonthViewType = { - date: any; - day: any; - dayData: any; - weekNumber: number; - title: string; - active: boolean; - today: boolean; -}; - -interface IMonthChild { - active: boolean; - date: Date; - day: number; - dayData: { - key: number; - shortTitle: string; - title: string; - }; - title: string; - today: boolean; - weekNumber: number; -} +import cloneDeep from "lodash/cloneDeep"; +import uniqBy from "lodash/uniqBy"; +// +import { months } from "../data"; +import { ChartDataType } from "../types"; +import { getNumberOfDaysBetweenTwoDates, getNumberOfDaysInMonth } from "./helpers"; +import { getWeeksBetweenTwoDates, IWeekBlock } from "./week-view"; export interface IMonthBlock { - children: IMonthChild[]; + today: boolean; month: number; + days: number; monthData: { key: number; shortTitle: string; @@ -41,157 +18,153 @@ export interface IMonthBlock { title: string; year: number; } -[]; -const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[] => { - const day: GetAllDaysInMonthInMonthViewType[] = []; - const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); - const currentDate = new Date(); +export interface IMonthView { + months: IMonthBlock[]; + weeks: IWeekBlock[]; +} - Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => { - const date: Date = generateDate(_day + 1, month, year); - day.push({ - date: date, - day: _day + 1, - dayData: weeks[date.getDay()], - weekNumber: getWeekNumberByDate(date), - title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, - active: false, - today: - currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 - ? true - : false, - }); - }); - - return day; -}; - -const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number): IMonthBlock => { - const currentMonth: number = month; - const currentYear: number = year; - - const monthPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: getAllDaysInMonthInMonthView(currentMonth, currentYear), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return monthPayload; -}; - -export const generateMonthChart = (monthPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = monthPayload; - const renderPayload: any = []; +/** + * Generate Month Chart data + * @param monthPayload + * @param side + * @returns + */ +const generateMonthChart = (monthPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => { + let renderState = cloneDeep(monthPayload); const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; + let filteredDates: IMonthView = { months: [], weeks: [] }; let minusDate: Date = new Date(); let plusDate: Date = new Date(); + let startDate = new Date(); + let endDate = new Date(); + + // if side is null generate months on both side of current date if (side === null) { const currentDate = renderState.data.currentDate; minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate); + startDate = filteredDates.weeks[0]?.startDate; + endDate = filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate; renderState = { ...renderState, data: { ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], + startDate, + endDate, }, }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; + } + // When side is left, generate more months on the left side of the start date + else if (side === "left") { + const chartStartDate = renderState.data.startDate; + const currentDate = targetDate ? targetDate : chartStartDate; - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); + plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate); + startDate = filteredDates.weeks[0]?.startDate; + endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); renderState = { ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, + data: { ...renderState.data, startDate }, }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; + } + // When side is right, generate more months on the right side of the end date + else if (side === "right") { + const chartEndDate = renderState.data.endDate; + const currentDate = targetDate ? targetDate : chartEndDate; - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); + minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate); + startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); + endDate = filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate; renderState = { ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, + data: { ...renderState.data, endDate: filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate }, }; } - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear)); - } + const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1; + const scrollWidth = days * monthPayload.data.dayWidth; - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; + return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth }; }; -export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate: Date) => { - let daysDifference: number = 0; - startDate.setHours(0, 0, 0, 0); - endDate.setHours(0, 0, 0, 0); +/** + * Get Month View data between two dates, i.e., Months and Weeks between two dates + * @param startDate + * @param endDate + * @returns + */ +const getMonthsViewBetweenTwoDates = (startDate: Date, endDate: Date): IMonthView => ({ + months: getMonthsBetweenTwoDates(startDate, endDate), + weeks: getWeeksBetweenTwoDates(startDate, endDate, false), +}); - const timeDifference: number = startDate.getTime() - endDate.getTime(); - daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24))); +/** + * generate array of months between two dates + * @param startDate + * @param endDate + * @returns + */ +export const getMonthsBetweenTwoDates = (startDate: Date, endDate: Date): IMonthBlock[] => { + const monthBlocks = []; - return daysDifference; + const startYear = startDate.getFullYear(); + const startMonth = startDate.getMonth(); + + const today = new Date(); + const todayMonth = today.getMonth(); + const todayYear = today.getFullYear(); + + const currentDate = new Date(startYear, startMonth); + + while (currentDate <= endDate) { + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + + monthBlocks.push({ + year: currentYear, + month: currentMonth, + monthData: months[currentMonth], + title: `${months[currentMonth].title} ${currentYear}`, + days: getNumberOfDaysInMonth(currentMonth, currentYear), + today: todayMonth === currentMonth && todayYear === currentYear, + }); + + currentDate.setMonth(currentDate.getMonth() + 1); + } + + return monthBlocks; }; -// calc item scroll position and width -export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, itemData: IGanttBlock) => { - let scrollPosition: number = 0; - let scrollWidth: number = 0; +/** + * Merge two MonthView data payloads + * @param a + * @param b + * @returns + */ +const mergeMonthRenderPayloads = (a: IMonthView, b: IMonthView): IMonthView => ({ + months: uniqBy([...a.months, ...b.months], (monthBlock) => `${monthBlock.month}_${monthBlock.year}`), + weeks: uniqBy( + [...a.weeks, ...b.weeks], + (weekBlock) => `${weekBlock.startDate.getTime()}_${weekBlock.endDate.getTime()}` + ), +}); - const { startDate } = chartData.data; - const { start_date: itemStartDate, target_date: itemTargetDate } = itemData; - - if (!itemStartDate || !itemTargetDate) return; - - startDate.setHours(0, 0, 0, 0); - itemStartDate.setHours(0, 0, 0, 0); - itemTargetDate.setHours(0, 0, 0, 0); - - const positionDaysDifference = findTotalDaysInRange(startDate, itemStartDate, false); - - if (!positionDaysDifference) return; - - scrollPosition = positionDaysDifference * chartData.data.width; - - let diffMonths = (itemStartDate.getFullYear() - startDate.getFullYear()) * 12; - diffMonths -= startDate.getMonth(); - diffMonths += itemStartDate.getMonth(); - - scrollPosition = scrollPosition + diffMonths; - // position code ends - - // width code starts - const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime(); - const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24))); - scrollWidth = (widthDaysDifference + 1) * chartData.data.width + 1; - // width code ends - - return { marginLeft: scrollPosition, width: scrollWidth }; +export const monthView = { + generateChart: generateMonthChart, + mergeRenderPayloads: mergeMonthRenderPayloads, }; diff --git a/web/core/components/gantt-chart/views/quarter-view.ts b/web/core/components/gantt-chart/views/quarter-view.ts new file mode 100644 index 000000000..a584a26e3 --- /dev/null +++ b/web/core/components/gantt-chart/views/quarter-view.ts @@ -0,0 +1,147 @@ +// +import { quarters } from "../data"; +import { ChartDataType } from "../types"; +import { getNumberOfDaysBetweenTwoDates } from "./helpers"; +import { getMonthsBetweenTwoDates, IMonthBlock } from "./month-view"; + +export interface IQuarterMonthBlock { + children: IMonthBlock[]; + quarterNumber: number; + shortTitle: string; + title: string; + year: number; + today: boolean; +} + +/** + * Generate Quarter Chart data, which in turn are months in an array + * @param quarterPayload + * @param side + * @returns + */ +const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => { + let renderState = quarterPayload; + + const range: number = renderState.data.approxFilterRange || 12; + let filteredDates: IMonthBlock[] = []; + let minusDate: Date = new Date(); + let plusDate: Date = new Date(); + + let startDate = new Date(); + let endDate = new Date(); + + // if side is null generate months on both side of current date + if (side === null) { + const currentDate = renderState.data.currentDate; + + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0); + + if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate); + + const startMonthBlock = filteredDates[0]; + const endMonthBlock = filteredDates[filteredDates.length - 1]; + startDate = new Date(startMonthBlock.year, startMonthBlock.month, 1); + endDate = new Date(endMonthBlock.year, endMonthBlock.month + 1, 0); + + renderState = { + ...renderState, + data: { + ...renderState.data, + startDate, + endDate, + }, + }; + } + // When side is left, generate more months on the left side of the start date + else if (side === "left") { + const chartStartDate = renderState.data.startDate; + const currentDate = targetDate ? targetDate : chartStartDate; + + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range / 2, 1); + plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth() - 1, 1); + + if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate); + + const startMonthBlock = filteredDates[0]; + startDate = new Date(startMonthBlock.year, startMonthBlock.month, 1); + endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); + renderState = { + ...renderState, + data: { ...renderState.data, startDate }, + }; + } + // When side is right, generate more months on the right side of the end date + else if (side === "right") { + const chartEndDate = renderState.data.endDate; + const currentDate = targetDate ? targetDate : chartEndDate; + + minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth() + 1, 1); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range / 2, 1); + + if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate); + + const endMonthBlock = filteredDates[filteredDates.length - 1]; + startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); + endDate = new Date(endMonthBlock.year, endMonthBlock.month + 1, 0); + renderState = { + ...renderState, + data: { ...renderState.data, endDate }, + }; + } + + const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1; + const scrollWidth = days * quarterPayload.data.dayWidth; + + return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth }; +}; + +/** + * Merge two Quarter data payloads + * @param a + * @param b + * @returns + */ +const mergeQuarterRenderPayloads = (a: IMonthBlock[], b: IMonthBlock[]) => [...a, ...b]; + +/** + * Group array of Months into Quarters, returns an array og Quarters and it's children Months + * @param monthBlocks + * @returns + */ +export const groupMonthsToQuarters = (monthBlocks: IMonthBlock[]): IQuarterMonthBlock[] => { + const quartersMap: { [key: string]: IQuarterMonthBlock } = {}; + + const today = new Date(); + const todayQuarterNumber = Math.floor(today.getMonth() / 3); + const todayYear = today.getFullYear(); + + for (const monthBlock of monthBlocks) { + const { month, year } = monthBlock; + + const quarterNumber = Math.floor(month / 3); + + const quarterKey = `Q${quarterNumber}-${year}`; + + if (quartersMap[quarterKey]) { + quartersMap[quarterKey].children.push(monthBlock); + } else { + const quarterData = quarters[quarterNumber]; + quartersMap[quarterKey] = { + children: [monthBlock], + quarterNumber, + shortTitle: quarterData.shortTitle, + title: `${quarterData.title} ${year}`, + year, + today: todayQuarterNumber === quarterNumber && todayYear === year, + }; + } + } + + return Object.values(quartersMap); +}; + +export const quarterView = { + generateChart: generateQuarterChart, + mergeRenderPayloads: mergeQuarterRenderPayloads, +}; diff --git a/web/core/components/gantt-chart/views/quater-view.ts b/web/core/components/gantt-chart/views/quater-view.ts deleted file mode 100644 index 9d45a43a1..000000000 --- a/web/core/components/gantt-chart/views/quater-view.ts +++ /dev/null @@ -1,114 +0,0 @@ -// types -import { weeks, months } from "../data"; -import { ChartDataType } from "../types"; -// data -// helpers -import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers"; - -const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - const today = new Date(); - - const weeksBetweenTwoDates = getWeeksByMonthAndYear(month, year); - - const weekPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: weeksBetweenTwoDates.map((weekData: any) => { - const date: Date = weekData.startDate; - return { - date: date, - startDate: weekData.startDate, - endDate: weekData.endDate, - day: date.getDay(), - dayData: weeks[date.getDay()], - weekNumber: weekData.weekNumber, - title: `W${weekData.weekNumber} (${date.getDate()})`, - active: false, - today: today >= weekData.startDate && today <= weekData.endDate ? true : false, - }; - }), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return weekPayload; -}; - -export const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = quarterPayload; - const renderPayload: any = []; - - const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; - let minusDate: Date = new Date(); - let plusDate: Date = new Date(); - - if (side === null) { - const currentDate = renderState.data.currentDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { - ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], - }, - }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, - }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, - }; - } - - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear)); - } - - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * quarterPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; -}; - -export const getNumberOfDaysBetweenTwoDatesInQuarter = (startDate: Date, endDate: Date) => { - let weeksDifference: number = 0; - - const timeDiff = Math.abs(endDate.getTime() - startDate.getTime()); - const diffDays = Math.ceil(timeDiff / (1000 * 3600 * 24)); - weeksDifference = Math.floor(diffDays / 7); - - return weeksDifference; -}; diff --git a/web/core/components/gantt-chart/views/week-view.ts b/web/core/components/gantt-chart/views/week-view.ts index bd4ae383d..65915274c 100644 --- a/web/core/components/gantt-chart/views/week-view.ts +++ b/web/core/components/gantt-chart/views/week-view.ts @@ -1,132 +1,203 @@ -// types +// import { weeks, months } from "../data"; import { ChartDataType } from "../types"; -// data -// helpers -import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; - -type GetAllDaysInMonthInMonthViewType = { - date: any; - day: any; - dayData: any; - weekNumber: number; - title: string; - active: boolean; - today: boolean; -}; -const getAllDaysInMonthInMonthView = (month: number, year: number) => { - const day: GetAllDaysInMonthInMonthViewType[] = []; - const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); - const currentDate = new Date(); - - Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => { - const date: Date = generateDate(_day + 1, month, year); - day.push({ - date: date, - day: _day + 1, - dayData: weeks[date.getDay()], - weekNumber: getWeekNumberByDate(date), - title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, - active: false, - today: - currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 - ? true - : false, - }); - }); - - return day; -}; - -const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - - const monthPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: getAllDaysInMonthInMonthView(currentMonth, currentYear), - title: `${months[currentMonth].title} ${currentYear}`, +import { getNumberOfDaysBetweenTwoDates, getWeekNumberByDate } from "./helpers"; +export interface IDayBlock { + date: Date; + day: number; + dayData: { + key: number; + shortTitle: string; + title: string; + abbreviation: string; }; + title: string; + today: boolean; +} - return monthPayload; -}; +export interface IWeekBlock { + children?: IDayBlock[]; + weekNumber: number; + weekData: { + shortTitle: string; + title: string; + }; + title: string; + startDate: Date; + endDate: Date; + startMonth: number; + startYear: number; + endMonth: number; + endYear: number; + today: boolean; +} -export const generateWeekChart = (monthPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = monthPayload; - const renderPayload: any = []; +/** + * Generate Week Chart data + * @param weekPayload + * @param side + * @returns + */ +const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => { + let renderState = weekPayload; const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; + let filteredDates: IWeekBlock[] = []; let minusDate: Date = new Date(); let plusDate: Date = new Date(); + let startDate = new Date(); + let endDate = new Date(); + + // if side is null generate weeks on both side of current date if (side === null) { const currentDate = renderState.data.currentDate; minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + startDate = filteredDates[0].startDate; + endDate = filteredDates[filteredDates.length - 1].endDate; renderState = { ...renderState, data: { ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], + startDate, + endDate, }, }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; + } + // When side is left, generate more weeks on the left side of the start date + else if (side === "left") { + const chartStartDate = renderState.data.startDate; + const currentDate = targetDate ? targetDate : chartStartDate; - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); + plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + startDate = filteredDates[0].startDate; + endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); renderState = { ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, + data: { ...renderState.data, startDate }, }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; + } + // When side is right, generate more weeks on the right side of the end date + else if (side === "right") { + const chartEndDate = renderState.data.endDate; + const currentDate = targetDate ? targetDate : chartEndDate; - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); + minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1); - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); + endDate = filteredDates[filteredDates.length - 1].endDate; renderState = { ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, + data: { ...renderState.data, endDate }, }; } - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear)); - } + const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1; + const scrollWidth = days * weekPayload.data.dayWidth; - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; + return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth }; }; -export const getNumberOfDaysBetweenTwoDatesInWeek = (startDate: Date, endDate: Date) => { - let daysDifference: number = 0; - startDate.setHours(0, 0, 0, 0); - endDate.setHours(0, 0, 0, 0); +/** + * Generate weeks array between two dates + * @param startDate + * @param endDate + * @param shouldPopulateDaysForWeek + * @returns + */ +export const getWeeksBetweenTwoDates = ( + startDate: Date, + endDate: Date, + shouldPopulateDaysForWeek: boolean = true +): IWeekBlock[] => { + const weeks: IWeekBlock[] = []; - const timeDifference: number = startDate.getTime() - endDate.getTime(); - daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24))); + const currentDate = new Date(startDate.getTime()); + const today = new Date(); - return daysDifference; + currentDate.setDate(currentDate.getDate() - currentDate.getDay()); + + while (currentDate <= endDate) { + const weekStartDate = new Date(currentDate.getTime()); + const weekEndDate = new Date(currentDate.getTime() + 6 * 24 * 60 * 60 * 1000); + + const monthAtStartOfTheWeek = weekStartDate.getMonth(); + const yearAtStartOfTheWeek = weekStartDate.getFullYear(); + const monthAtEndOfTheWeek = weekEndDate.getMonth(); + const yearAtEndOfTheWeek = weekEndDate.getFullYear(); + + const weekNumber = getWeekNumberByDate(currentDate); + + weeks.push({ + children: shouldPopulateDaysForWeek ? populateDaysForWeek(weekStartDate) : undefined, + weekNumber, + weekData: { + shortTitle: `w${weekNumber}`, + title: `Week ${weekNumber}`, + }, + title: + monthAtStartOfTheWeek === monthAtEndOfTheWeek + ? `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek}` + : `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek} - ${months[monthAtEndOfTheWeek].abbreviation} ${yearAtEndOfTheWeek}`, + startMonth: monthAtStartOfTheWeek, + startYear: yearAtStartOfTheWeek, + endMonth: monthAtEndOfTheWeek, + endYear: yearAtEndOfTheWeek, + startDate: weekStartDate, + endDate: weekEndDate, + today: today >= weekStartDate && today <= weekEndDate ? true : false, + }); + + currentDate.setDate(currentDate.getDate() + 7); + } + + return weeks; +}; + +/** + * return back array of 7 days from the date provided + * @param startDate + * @returns + */ +const populateDaysForWeek = (startDate: Date): IDayBlock[] => { + const currentDate = new Date(startDate); + const days: IDayBlock[] = []; + const today = new Date(); + + for (let i = 0; i < 7; i++) { + days.push({ + date: new Date(currentDate), + day: currentDate.getDay(), + dayData: weeks[currentDate.getDay()], + title: `${weeks[currentDate.getDay()].abbreviation} ${currentDate.getDate()}`, + today: today.setHours(0, 0, 0, 0) == currentDate.setHours(0, 0, 0, 0), + }); + currentDate.setDate(currentDate.getDate() + 1); + } + return days; +}; + +/** + * Merge two Week data payloads + * @param a + * @param b + * @returns + */ +const mergeWeekRenderPayloads = (a: IWeekBlock[], b: IWeekBlock[]) => [...a, ...b]; + +export const weekView = { + generateChart: generateWeekChart, + mergeRenderPayloads: mergeWeekRenderPayloads, }; diff --git a/web/core/components/gantt-chart/views/year-view.ts b/web/core/components/gantt-chart/views/year-view.ts deleted file mode 100644 index 69ff9dae8..000000000 --- a/web/core/components/gantt-chart/views/year-view.ts +++ /dev/null @@ -1,114 +0,0 @@ -// types -import { weeks, months } from "../data"; -import { ChartDataType } from "../types"; -// data -// helpers -import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers"; - -const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => { - const currentMonth: number = month; - const currentYear: number = year; - const today = new Date(); - - const weeksBetweenTwoDates = getWeeksByMonthAndYear(month, year); - - const weekPayload = { - year: currentYear, - month: currentMonth, - monthData: months[currentMonth], - children: weeksBetweenTwoDates.map((weekData: any) => { - const date: Date = weekData.startDate; - return { - date: date, - startDate: weekData.startDate, - endDate: weekData.endDate, - day: date.getDay(), - dayData: weeks[date.getDay()], - weekNumber: weekData.weekNumber, - title: `W${weekData.weekNumber} (${date.getDate()})`, - active: false, - today: today >= weekData.startDate && today <= weekData.endDate ? true : false, - }; - }), - title: `${months[currentMonth].title} ${currentYear}`, - }; - - return weekPayload; -}; - -export const generateYearChart = (yearPayload: ChartDataType, side: null | "left" | "right") => { - let renderState = yearPayload; - const renderPayload: any = []; - - const range: number = renderState.data.approxFilterRange || 6; - let filteredDates: Date[] = []; - let minusDate: Date = new Date(); - let plusDate: Date = new Date(); - - if (side === null) { - const currentDate = renderState.data.currentDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { - ...renderState.data, - startDate: filteredDates[0], - endDate: filteredDates[filteredDates.length - 1], - }, - }; - } else if (side === "left") { - const currentDate = renderState.data.startDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, startDate: filteredDates[0] }, - }; - } else if (side === "right") { - const currentDate = renderState.data.endDate; - - minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1); - plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0); - - if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); - - renderState = { - ...renderState, - data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] }, - }; - } - - if (filteredDates && filteredDates.length > 0) - for (const currentDate in filteredDates) { - const date = filteredDates[parseInt(currentDate)]; - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear)); - } - - const scrollWidth = - renderPayload - .map((monthData: any) => monthData.children.length) - .reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width; - - return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; -}; - -export const getNumberOfDaysBetweenTwoDatesInYear = (startDate: Date, endDate: Date) => { - let weeksDifference: number = 0; - - const timeDiff = Math.abs(endDate.getTime() - startDate.getTime()); - const diffDays = Math.ceil(timeDiff / (1000 * 3600 * 24)); - weeksDifference = Math.floor(diffDays / 7); - - return weeksDifference; -}; diff --git a/web/core/components/global/index.ts b/web/core/components/global/index.ts new file mode 100644 index 000000000..1230b8384 --- /dev/null +++ b/web/core/components/global/index.ts @@ -0,0 +1 @@ +export * from "./product-updates"; diff --git a/web/core/components/global/product-updates/footer.tsx b/web/core/components/global/product-updates/footer.tsx new file mode 100644 index 000000000..6dd263833 --- /dev/null +++ b/web/core/components/global/product-updates/footer.tsx @@ -0,0 +1,62 @@ +import Image from "next/image"; +// ui +import { getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// assets +import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; + +export const ProductUpdatesFooter = () => ( + +); diff --git a/web/core/components/global/product-updates/index.ts b/web/core/components/global/product-updates/index.ts new file mode 100644 index 000000000..6886b5503 --- /dev/null +++ b/web/core/components/global/product-updates/index.ts @@ -0,0 +1,2 @@ +export * from "./modal"; +export * from "./footer"; diff --git a/web/core/components/global/product-updates/modal.tsx b/web/core/components/global/product-updates/modal.tsx new file mode 100644 index 000000000..57beaa681 --- /dev/null +++ b/web/core/components/global/product-updates/modal.tsx @@ -0,0 +1,84 @@ +import { FC, useRef } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// editor +import { DocumentReadOnlyEditorWithRef, EditorRefApi } from "@plane/editor"; +// ui +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// helpers +import { LogoSpinner } from "@/components/common"; +import { ProductUpdatesFooter } from "@/components/global"; +// plane web components +import { ProductUpdatesHeader } from "@/plane-web/components/global"; +// services +import { InstanceService } from "@/services/instance.service"; + +const instanceService = new InstanceService(); + +export type ProductUpdatesModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const ProductUpdatesModal: FC = observer((props) => { + const { isOpen, handleClose } = props; + // refs + const editorRef = useRef(null); + // swr + const { data, isLoading, error } = useSWR(`INSTANCE_CHANGELOG`, () => instanceService.getInstanceChangeLog(), { + shouldRetryOnError: false, + revalidateIfStale: false, + revalidateOnFocus: false, + }); + + return ( + + +
+ {!isLoading && !!error ? ( +
+
We are having trouble fetching the updates.
+
+ Please visit{" "} + + our changelogs + {" "} + for the latest updates. +
+
+ ) : isLoading ? ( +
+ +
+ ) : ( +
+ {data?.id && ( +

"} + containerClassName="p-0 border-none" + mentionHandler={{ + highlights: () => Promise.resolve([]), + }} + embedHandler={{ + issue: { + widgetCallback: () => <>, + }, + }} + fileHandler={{ + getAssetSrc: () => Promise.resolve(""), + }} + /> + )} +
+ )} +
+ +
+ ); +}); diff --git a/web/core/components/inbox/content/inbox-issue-header.tsx b/web/core/components/inbox/content/inbox-issue-header.tsx index 19a39e5e9..ad7d63ff0 100644 --- a/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/web/core/components/inbox/content/inbox-issue-header.tsx @@ -21,12 +21,11 @@ import { DeclineIssueModal, DeleteInboxIssueModal, InboxIssueActionsMobileHeader, - InboxIssueCreateEditModalRoot, InboxIssueSnoozeModal, InboxIssueStatus, SelectDuplicateInboxIssueModal, } from "@/components/inbox"; -import { IssueUpdateStatus } from "@/components/issues"; +import { CreateUpdateIssueModal, IssueUpdateStatus } from "@/components/issues"; // helpers import { findHowManyDaysLeft } from "@/helpers/date-time.helper"; import { EInboxIssueStatus } from "@/helpers/inbox.helper"; @@ -70,6 +69,7 @@ export const InboxIssueActionsHeader: FC = observer((p const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox(); const { data: currentUser } = useUser(); const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); const router = useAppRouter(); const { getProjectById } = useProject(); @@ -217,11 +217,12 @@ export const InboxIssueActionsHeader: FC = observer((p }; useEffect(() => { + if (isSubmitting === "submitting") return; if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown); return () => { if (!isNotificationEmbed) document.removeEventListener("keydown", onKeyDown); }; - }, [onKeyDown, isNotificationEmbed]); + }, [onKeyDown, isNotificationEmbed, isSubmitting]); if (!inboxIssue) return null; @@ -234,16 +235,19 @@ export const InboxIssueActionsHeader: FC = observer((p value={inboxIssue?.duplicate_to} onSubmit={handleInboxIssueDuplicate} /> - - setAcceptIssueModal(false)} - issue={inboxIssue?.issue} - onSubmit={handleInboxIssueAccept} + setAcceptIssueModal(false)} + beforeFormSubmit={handleInboxIssueAccept} + withDraftIssueWrapper={false} + fetchIssueDetails={false} + modalTitle={`Move ${currentProjectDetails?.identifier}-${issue?.sequence_id} to project issues`} + primaryButtonText={{ + default: "Add to project", + loading: "Adding", + }} /> - = observer((props) => { const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { captureIssueEvent } = useEventTracker(); const { loader } = useProjectInbox(); + const { getProjectById } = useProject(); + const { removeIssue, archiveIssue } = useIssueDetail(); useEffect(() => { if (isSubmitting === "submitted") { @@ -50,7 +60,17 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, [isSubmitting, setShowAlert, setIsSubmitting]); + // dervied values const issue = inboxIssue.issue; + const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; + + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectId, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + if (!issue) return <>; const issueOperations: TIssueOperations = useMemo( @@ -61,7 +81,31 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }, // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { - return; + try { + await removeIssue(workspaceSlug, projectId, _issueId); + setToast({ + title: "Success!", + type: TOAST_TYPE.SUCCESS, + message: "Issue deleted successfully", + }); + captureIssueEvent({ + eventName: ISSUE_DELETED, + payload: { id: _issueId, state: "SUCCESS", element: "Issue detail page" }, + path: pathname, + }); + } catch (error) { + console.log("Error in deleting issue:", error); + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: "Issue delete failed", + }); + captureIssueEvent({ + eventName: ISSUE_DELETED, + payload: { id: _issueId, state: "FAILED", element: "Issue detail page" }, + path: pathname, + }); + } }, update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { try { @@ -92,6 +136,23 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, + archive: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await archiveIssue(workspaceSlug, projectId, issueId); + captureIssueEvent({ + eventName: ISSUE_ARCHIVED, + payload: { id: issueId, state: "SUCCESS", element: "Issue details page" }, + path: pathname, + }); + } catch (error) { + console.log("Error in archiving issue:", error); + captureIssueEvent({ + eventName: ISSUE_ARCHIVED, + payload: { id: issueId, state: "FAILED", element: "Issue details page" }, + path: pathname, + }); + } + }, }), [inboxIssue] ); @@ -101,6 +162,16 @@ export const InboxIssueMainContent: React.FC = observer((props) => { return ( <>
+ {duplicateIssues.length > 0 && ( + + )} = observer((props) => { ); const isEditable = - allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT) || - inboxIssue.created_by === currentUser?.id; + allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) || + inboxIssue?.created_by === currentUser?.id; const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST; const isOwner = inboxIssue?.issue.created_by === currentUser?.id; diff --git a/web/core/components/inbox/inbox-filter/applied-filters/member.tsx b/web/core/components/inbox/inbox-filter/applied-filters/member.tsx index 2bf69b023..aff556c62 100644 --- a/web/core/components/inbox/inbox-filter/applied-filters/member.tsx +++ b/web/core/components/inbox/inbox-filter/applied-filters/member.tsx @@ -3,8 +3,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; +// plane types import { TInboxIssueFilterMemberKeys } from "@plane/types"; +// plane ui import { Avatar, Tag } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember, useProjectInbox } from "@/hooks/store"; @@ -37,7 +41,12 @@ export const InboxIssueAppliedFiltersMember: FC return (
- +
{optionDetail?.display_name}
= observer((props: Props) => { key={`members-${member.id}`} isChecked={filterValue?.includes(member.id) ? true : false} onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))} - icon={} + icon={ + + } title={currentUser?.id === member.id ? "You" : member?.display_name} /> ); diff --git a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx b/web/core/components/inbox/modals/create-edit-modal/create-root.tsx deleted file mode 100644 index b4dfd6904..000000000 --- a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -import { FC, FormEvent, useCallback, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -// editor -import { EditorRefApi } from "@plane/editor"; -// types -import { TIssue } from "@plane/types"; -import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { - InboxIssueTitle, - InboxIssueDescription, - InboxIssueProperties, -} from "@/components/inbox/modals/create-edit-modal"; -// constants -import { ISSUE_CREATED } from "@/constants/event-tracker"; -import { ETabIndices } from "@/constants/tab-indices"; -// helpers -import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -import { getTabIndex } from "@/helpers/tab-indices.helper"; -// hooks -import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import useKeypress from "@/hooks/use-keypress"; -import { usePlatformOS } from "@/hooks/use-platform-os"; - -type TInboxIssueCreateRoot = { - workspaceSlug: string; - projectId: string; - handleModalClose: () => void; -}; - -export const defaultIssueData: Partial = { - id: undefined, - name: "", - description_html: "", - priority: "none", - state_id: "", - label_ids: [], - assignee_ids: [], - start_date: renderFormattedPayloadDate(new Date()), - target_date: "", -}; - -export const InboxIssueCreateRoot: FC = observer((props) => { - const { workspaceSlug, projectId, handleModalClose } = props; - const router = useAppRouter(); - const pathname = usePathname(); - // refs - const descriptionEditorRef = useRef(null); - const submitBtnRef = useRef(null); - // hooks - const { captureIssueEvent } = useEventTracker(); - const { createInboxIssue } = useProjectInbox(); - const { getWorkspaceBySlug } = useWorkspace(); - const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; - const { isMobile } = usePlatformOS(); - // states - const [createMore, setCreateMore] = useState(false); - const [formSubmitting, setFormSubmitting] = useState(false); - const [formData, setFormData] = useState>(defaultIssueData); - const handleFormData = useCallback( - >(issueKey: T, issueValue: Partial[T]) => { - setFormData({ - ...formData, - [issueKey]: issueValue, - }); - }, - [formData] - ); - - const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile); - - const handleEscKeyDown = (event: KeyboardEvent) => { - if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { - handleModalClose(); - } else { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Editor is still processing changes. Please wait before proceeding.", - }); - event.preventDefault(); // Prevent default action if editor is not ready to discard - } - }; - - useKeypress("Escape", handleEscKeyDown); - - const handleFormSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Editor is still processing changes. Please wait before proceeding.", - }); - return; - } - - const payload: Partial = { - name: formData.name || "", - description_html: formData.description_html || "

", - priority: formData.priority || "none", - state_id: formData.state_id || "", - label_ids: formData.label_ids || [], - assignee_ids: formData.assignee_ids || [], - target_date: formData.target_date || null, - }; - setFormSubmitting(true); - - await createInboxIssue(workspaceSlug, projectId, payload) - .then((res) => { - if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); - handleModalClose(); - } else { - descriptionEditorRef?.current?.clearEditor(); - setFormData(defaultIssueData); - } - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "SUCCESS", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: `Success!`, - message: "Issue created successfully.", - }); - }) - .catch((error) => { - console.error(error); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { - ...formData, - state: "FAILED", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: `Error!`, - message: "Some error occurred. Please try again.", - }); - }); - setFormSubmitting(false); - }; - - const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; - - if (!workspaceSlug || !projectId || !workspaceId) return <>; - return ( -
-
-

Create intake issue

-
- - submitBtnRef?.current?.click()} - /> - -
-
-
-
setCreateMore((prevData) => !prevData)} - role="button" - tabIndex={getIndex("create_more")} - > - {}} size="sm" /> - Create more -
-
- - -
-
- - ); -}); diff --git a/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx b/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx deleted file mode 100644 index 05ff4f1e5..000000000 --- a/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { FC, useCallback, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -// editor -import { EditorRefApi } from "@plane/editor"; -// types -import { TIssue } from "@plane/types"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { - InboxIssueTitle, - InboxIssueDescription, - InboxIssueProperties, -} from "@/components/inbox/modals/create-edit-modal"; -// constants -import { ISSUE_UPDATED } from "@/constants/event-tracker"; -// helpers -import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -// hooks -import { useEventTracker, useInboxIssues, useProject, useWorkspace } from "@/hooks/store"; - -type TInboxIssueEditRoot = { - workspaceSlug: string; - projectId: string; - issueId: string; - issue: Partial; - handleModalClose: () => void; - onSubmit?: () => void; -}; - -export const InboxIssueEditRoot: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, issue, handleModalClose, onSubmit } = props; - const pathname = usePathname(); - // refs - const descriptionEditorRef = useRef(null); - const submitBtnRef = useRef(null); - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { currentProjectDetails } = useProject(); - const { updateProjectIssue } = useInboxIssues(issueId); - const { getWorkspaceBySlug } = useWorkspace(); - const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; - // states - const [formSubmitting, setFormSubmitting] = useState(false); - const [formData, setFormData] = useState | undefined>(undefined); - const handleFormData = useCallback( - >(issueKey: T, issueValue: Partial[T]) => { - setFormData({ - ...formData, - [issueKey]: issueValue, - }); - }, - [formData] - ); - - useEffect(() => { - if (formData?.id != issue?.id) - setFormData({ - id: issue?.id || undefined, - name: issue?.name ?? "", - description_html: issue?.description_html ?? "

", - priority: issue?.priority ?? "none", - state_id: issue?.state_id ?? "", - label_ids: issue?.label_ids ?? [], - assignee_ids: issue?.assignee_ids ?? [], - start_date: renderFormattedPayloadDate(issue?.start_date) ?? "", - target_date: renderFormattedPayloadDate(issue?.target_date) ?? "", - }); - }, [issue, formData]); - - const handleFormSubmit = async () => { - const payload: Partial = { - name: formData?.name || "", - description_html: formData?.description_html || "

", - priority: formData?.priority || "none", - state_id: formData?.state_id || "", - label_ids: formData?.label_ids || [], - assignee_ids: formData?.assignee_ids || [], - start_date: formData?.start_date || undefined, - target_date: formData?.target_date || undefined, - cycle_id: formData?.cycle_id || "", - module_ids: formData?.module_ids || [], - estimate_point: formData?.estimate_point || undefined, - parent_id: formData?.parent_id || null, - }; - setFormSubmitting(true); - - onSubmit && (await onSubmit()); - await updateProjectIssue(payload) - .then(async () => { - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { - ...formData, - state: "SUCCESS", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: `Success!`, - message: "Issue created successfully.", - }); - descriptionEditorRef?.current?.clearEditor(); - handleModalClose(); - }) - .catch((error) => { - console.error(error); - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { - ...formData, - state: "FAILED", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: `Error!`, - message: "Some error occurred. Please try again.", - }); - }); - setFormSubmitting(false); - }; - - const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; - - if (!workspaceSlug || !projectId || !workspaceId || !formData) return <>; - return ( - <> -
-

- Move {currentProjectDetails?.identifier}-{issue?.sequence_id} to project issues -

-
- - submitBtnRef?.current?.click()} - /> - -
-
-
- - -
- - ); -}); diff --git a/web/core/components/inbox/modals/create-edit-modal/modal.tsx b/web/core/components/inbox/modals/create-edit-modal/modal.tsx deleted file mode 100644 index 5d51477b6..000000000 --- a/web/core/components/inbox/modals/create-edit-modal/modal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { FC } from "react"; -// types -import { TIssue } from "@plane/types"; -// ui -import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; -// components -import { InboxIssueCreateRoot, InboxIssueEditRoot } from "@/components/inbox/modals/create-edit-modal"; - -type TInboxIssueCreateEditModalRoot = { - workspaceSlug: string; - projectId: string; - modalState: boolean; - handleModalClose: () => void; - issue: Partial | undefined; - onSubmit?: () => void; -}; - -export const InboxIssueCreateEditModalRoot: FC = (props) => { - const { workspaceSlug, projectId, modalState, handleModalClose, issue, onSubmit } = props; - - return ( - - {issue && issue?.id ? ( - - ) : ( - - )} - - ); -}; diff --git a/web/core/components/inbox/modals/create-modal/create-root.tsx b/web/core/components/inbox/modals/create-modal/create-root.tsx new file mode 100644 index 000000000..4f7bbd4b4 --- /dev/null +++ b/web/core/components/inbox/modals/create-modal/create-root.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { FC, FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// editor +import { EditorRefApi } from "@plane/editor"; +// types +import { TIssue } from "@plane/types"; +import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { InboxIssueTitle, InboxIssueDescription, InboxIssueProperties } from "@/components/inbox/modals/create-modal"; +// constants +import { ISSUE_CREATED } from "@/constants/event-tracker"; +import { ETabIndices } from "@/constants/tab-indices"; +// helpers +import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getTabIndex } from "@/helpers/tab-indices.helper"; +// hooks +import { useEventTracker, useProject, useProjectInbox, useWorkspace } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useKeypress from "@/hooks/use-keypress"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; +import { FileService } from "@/services/file.service"; + +const fileService = new FileService(); + +type TInboxIssueCreateRoot = { + workspaceSlug: string; + projectId: string; + handleModalClose: () => void; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (value: boolean) => void; +}; + +export const defaultIssueData: Partial = { + id: undefined, + name: "", + description_html: "", + priority: "none", + state_id: "", + label_ids: [], + assignee_ids: [], + start_date: renderFormattedPayloadDate(new Date()), + target_date: "", +}; + +export const InboxIssueCreateRoot: FC = observer((props) => { + const { workspaceSlug, projectId, handleModalClose, isDuplicateModalOpen, handleDuplicateIssueModal } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); + // router + const router = useAppRouter(); + const pathname = usePathname(); + // refs + const descriptionEditorRef = useRef(null); + const submitBtnRef = useRef(null); + const formRef = useRef(null); + const modalContainerRef = useRef(null); + // hooks + const { captureIssueEvent } = useEventTracker(); + const { createInboxIssue } = useProjectInbox(); + const { getWorkspaceBySlug } = useWorkspace(); + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; + const { isMobile } = usePlatformOS(); + const { getProjectById } = useProject(); + // states + const [createMore, setCreateMore] = useState(false); + const [formSubmitting, setFormSubmitting] = useState(false); + const [formData, setFormData] = useState>(defaultIssueData); + const handleFormData = useCallback( + >(issueKey: T, issueValue: Partial[T]) => { + setFormData({ + ...formData, + [issueKey]: issueValue, + }); + }, + [formData] + ); + + // derived values + const projectDetails = projectId ? getProjectById(projectId) : undefined; + + const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile); + + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectId, { + name: formData?.name, + description_html: formData?.description_html, + }); + + const handleEscKeyDown = (event: KeyboardEvent) => { + if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { + handleModalClose(); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + event.preventDefault(); // Prevent default action if editor is not ready to discard + } + }; + + useKeypress("Escape", handleEscKeyDown); + + useEffect(() => { + const formElement = formRef?.current; + const modalElement = modalContainerRef?.current; + + if (!formElement || !modalElement) return; + + const resizeObserver = new ResizeObserver(() => { + modalElement.style.maxHeight = `${formElement?.offsetHeight}px`; + }); + + resizeObserver.observe(formElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [formRef, modalContainerRef]); + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + return; + } + + const payload: Partial = { + name: formData.name || "", + description_html: formData.description_html || "

", + priority: formData.priority || "none", + state_id: formData.state_id || "", + label_ids: formData.label_ids || [], + assignee_ids: formData.assignee_ids || [], + target_date: formData.target_date || null, + }; + setFormSubmitting(true); + + await createInboxIssue(workspaceSlug, projectId, payload) + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res?.issue.id ?? "", { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } + if (!createMore) { + router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); + handleModalClose(); + } else { + descriptionEditorRef?.current?.clearEditor(); + setFormData(defaultIssueData); + } + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { + ...formData, + state: "SUCCESS", + element: "Inbox page", + }, + path: pathname, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: `Success!`, + message: "Issue created successfully.", + }); + }) + .catch((error) => { + console.error(error); + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { + ...formData, + state: "FAILED", + element: "Inbox page", + }, + path: pathname, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: `Error!`, + message: "Some error occurred. Please try again.", + }); + }); + setFormSubmitting(false); + }; + + const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; + + const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0; + + if (!workspaceSlug || !projectId || !workspaceId) return <>; + return ( +
+
+
+
+
+

Create intake issue

+ {duplicateIssues?.length > 0 && ( + 1 ? "s" : ""} found!`} + handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)} + /> + )} +
+
+ + submitBtnRef?.current?.click()} + onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} + /> + +
+
+
+
setCreateMore((prevData) => !prevData)} + role="button" + tabIndex={getIndex("create_more")} + > + {}} size="sm" /> + Create more +
+
+ + +
+
+ +
+ {shouldRenderDuplicateModal && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/inbox/modals/create-edit-modal/index.ts b/web/core/components/inbox/modals/create-modal/index.ts similarity index 84% rename from web/core/components/inbox/modals/create-edit-modal/index.ts rename to web/core/components/inbox/modals/create-modal/index.ts index 484c1a31e..907c3ddba 100644 --- a/web/core/components/inbox/modals/create-edit-modal/index.ts +++ b/web/core/components/inbox/modals/create-modal/index.ts @@ -1,6 +1,5 @@ export * from "./modal"; export * from "./create-root"; -export * from "./edit-root"; export * from "./issue-title"; export * from "./issue-description"; export * from "./issue-properties"; diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/core/components/inbox/modals/create-modal/issue-description.tsx similarity index 68% rename from web/core/components/inbox/modals/create-edit-modal/issue-description.tsx rename to web/core/components/inbox/modals/create-modal/issue-description.tsx index 4daface0b..b9bad6c11 100644 --- a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { EditorRefApi } from "@plane/editor"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -18,6 +19,9 @@ import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TInboxIssueDescription = { containerClassName?: string; @@ -28,12 +32,22 @@ type TInboxIssueDescription = { handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; editorRef: RefObject; onEnterKeyPress?: (e?: any) => void; + onAssetUpload?: (assetId: string) => void; }; // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } = - props; + const { + containerClassName, + workspaceSlug, + projectId, + workspaceId, + data, + handleData, + editorRef, + onEnterKeyPress, + onAssetUpload, + } = props; // hooks const { loader } = useProjectInbox(); const { isMobile } = usePlatformOS(); @@ -61,6 +75,24 @@ export const InboxIssueDescription: FC = observer((props containerClassName={containerClassName} onEnterKeyPress={onEnterKeyPress} tabIndex={getIndex("description_html")} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: data.id ?? "", + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + onAssetUpload?.(asset_id); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ); }); diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx b/web/core/components/inbox/modals/create-modal/issue-properties.tsx similarity index 95% rename from web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx rename to web/core/components/inbox/modals/create-modal/issue-properties.tsx index b17a60abd..ce37f07b1 100644 --- a/web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -171,13 +171,13 @@ export const InboxIssueProperties: FC = observer((props) {/* add parent */} {isVisible && ( - <> +
{selectedParentIssue ? ( @@ -188,6 +188,8 @@ export const InboxIssueProperties: FC = observer((props) } placement="bottom-start" + className="h-full w-full" + customButtonClassName="h-full" tabIndex={getIndex("parent_id")} > <> @@ -208,7 +210,7 @@ export const InboxIssueProperties: FC = observer((props) ) : (
)}
); diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-title.tsx b/web/core/components/inbox/modals/create-modal/issue-title.tsx similarity index 100% rename from web/core/components/inbox/modals/create-edit-modal/issue-title.tsx rename to web/core/components/inbox/modals/create-modal/issue-title.tsx diff --git a/web/core/components/inbox/modals/create-modal/modal.tsx b/web/core/components/inbox/modals/create-modal/modal.tsx new file mode 100644 index 000000000..6cfa154c8 --- /dev/null +++ b/web/core/components/inbox/modals/create-modal/modal.tsx @@ -0,0 +1,43 @@ +"use-client"; + +import { FC, useState } from "react"; +// ui +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// components +import { InboxIssueCreateRoot } from "@/components/inbox/modals/create-modal"; + +type TInboxIssueCreateModalRoot = { + workspaceSlug: string; + projectId: string; + modalState: boolean; + handleModalClose: () => void; +}; + +export const InboxIssueCreateModalRoot: FC = (props) => { + const { workspaceSlug, projectId, modalState, handleModalClose } = props; + // states + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + // handlers + const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value); + + return ( + { + handleModalClose(); + setIsDuplicateModalOpen(false); + }} + position={EModalPosition.TOP} + width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL} + className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear" + > + + + ); +}; diff --git a/web/core/components/inbox/modals/index.ts b/web/core/components/inbox/modals/index.ts index 91e185ffe..78d9e6561 100644 --- a/web/core/components/inbox/modals/index.ts +++ b/web/core/components/inbox/modals/index.ts @@ -1,4 +1,4 @@ -export * from "./create-edit-modal"; +export * from "./create-modal"; export * from "./decline-issue-modal"; export * from "./delete-issue-modal"; export * from "./select-duplicate"; diff --git a/web/core/components/inbox/modals/snooze-issue-modal.tsx b/web/core/components/inbox/modals/snooze-issue-modal.tsx index edcaa4190..02134d566 100644 --- a/web/core/components/inbox/modals/snooze-issue-modal.tsx +++ b/web/core/components/inbox/modals/snooze-issue-modal.tsx @@ -54,11 +54,11 @@ export const InboxIssueSnoozeModal: FC = (props) => }} mode="single" className="rounded-md border border-custom-border-200 p-3" - // disabled={[ - // { - // before: tomorrow, - // }, - // ]} + disabled={[ + { + before: new Date(), + }, + ]} />
{/* created by */} - {createdByDetails && } + {createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? ( + + ) : createdByDetails ? ( + + ) : null}
diff --git a/web/core/components/instance/index.ts b/web/core/components/instance/index.ts index a85c1dd5b..43e76eba7 100644 --- a/web/core/components/instance/index.ts +++ b/web/core/components/instance/index.ts @@ -1 +1,2 @@ export * from "./not-ready-view"; +export * from "./maintenance-view"; diff --git a/web/core/components/instance/maintenance-view.tsx b/web/core/components/instance/maintenance-view.tsx new file mode 100644 index 000000000..8dd4c34e7 --- /dev/null +++ b/web/core/components/instance/maintenance-view.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { FC } from "react"; +import Image from "next/image"; +// ui +import { Button } from "@plane/ui"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// components +import { MaintenanceMessage } from "@/plane-web/components/instance"; +// images +import maintenanceModeImage from "@/public/maintenance-mode.webp"; + +export const MaintenanceView: FC = () => ( + +
+
+ +
+
+ + +
+
+
+); diff --git a/web/core/components/integration/github/single-user-select.tsx b/web/core/components/integration/github/single-user-select.tsx index a936db630..c2ecda03e 100644 --- a/web/core/components/integration/github/single-user-select.tsx +++ b/web/core/components/integration/github/single-user-select.tsx @@ -2,15 +2,18 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; +// plane types import { IGithubRepoCollaborator } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui // types import { IUserDetails } from "./root"; -// fetch-keys type Props = { collaborator: IGithubRepoCollaborator; @@ -53,7 +56,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, query: member.member?.display_name ?? "", content: (
- + {member.member?.display_name}
), diff --git a/web/core/components/integration/jira/import-users.tsx b/web/core/components/integration/jira/import-users.tsx index 3b7a7cd73..6bffece7f 100644 --- a/web/core/components/integration/jira/import-users.tsx +++ b/web/core/components/integration/jira/import-users.tsx @@ -4,14 +4,16 @@ import { FC } from "react"; import { useParams } from "next/navigation"; import { useFormContext, useFieldArray, Controller } from "react-hook-form"; import useSWR from "swr"; +// plane types import { IJiraImporterForm } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui -// types -// fetch keys const workspaceService = new WorkspaceService(); @@ -42,7 +44,7 @@ export const JiraImportUsers: FC = () => { query: member.member.display_name ?? "", content: (
- + {member.member.display_name}
), diff --git a/web/core/components/issues/archive-issue-modal.tsx b/web/core/components/issues/archive-issue-modal.tsx index 2c9a04ddf..4f944e26a 100644 --- a/web/core/components/issues/archive-issue-modal.tsx +++ b/web/core/components/issues/archive-issue-modal.tsx @@ -2,16 +2,16 @@ import { useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; -import { TIssue } from "@plane/types"; -// hooks +// types +import { TDeDupeIssue, TIssue } from "@plane/types"; +// ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// hooks import { useProject } from "@/hooks/store"; import { useIssues } from "@/hooks/store/use-issues"; -// ui -// types type Props = { - data?: TIssue; + data?: TIssue | TDeDupeIssue; dataId?: string | null | undefined; handleClose: () => void; isOpen: boolean; diff --git a/web/core/components/issues/attachment/attachment-detail.tsx b/web/core/components/issues/attachment/attachment-detail.tsx index 255b955bb..315d579f5 100644 --- a/web/core/components/issues/attachment/attachment-detail.tsx +++ b/web/core/components/issues/attachment/attachment-detail.tsx @@ -13,24 +13,25 @@ import { IssueAttachmentDeleteModal } from "@/components/issues"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; import { truncateText } from "@/helpers/string.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types -import { TAttachmentOperations } from "./root"; +import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; -type TAttachmentOperationsRemoveModal = Exclude; +type TAttachmentOperationsRemoveModal = Exclude; type TIssueAttachmentsDetail = { attachmentId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentOperationsRemoveModal; disabled?: boolean; }; export const IssueAttachmentsDetail: FC = observer((props) => { // props - const { attachmentId, handleAttachmentOperations, disabled } = props; + const { attachmentId, attachmentHelpers, disabled } = props; // store hooks const { getUserDetails } = useMember(); const { @@ -40,6 +41,10 @@ export const IssueAttachmentsDetail: FC = observer((pro const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false); // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 28); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -51,18 +56,18 @@ export const IssueAttachmentsDetail: FC = observer((pro setIsDeleteIssueAttachmentModalOpen(false)} - handleAttachmentOperations={handleAttachmentOperations} + attachmentOperations={attachmentHelpers.operations} attachmentId={attachmentId} /> )}
- +
-
{getFileIcon(getFileExtension(attachment.asset), 28)}
+
{fileIcon}
- - {truncateText(`${getFileName(attachment.attributes.name)}`, 10)} + + {truncateText(`${fileName}`, 10)} = observer((pro
- {getFileExtension(attachment.asset).toUpperCase()} + {fileExtension.toUpperCase()} {convertBytesToSize(attachment.attributes.size)}
diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx index a0126b251..d377bd90a 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -3,122 +3,130 @@ import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; // hooks -import {TOAST_TYPE, setToast } from "@plane/ui"; -import { MAX_FILE_SIZE } from "@/constants/common"; -import { generateFileName } from "@/helpers/attachment.helper"; -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +// types +import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentsListItem } from "./attachment-list-item"; +import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item"; // types import { IssueAttachmentDeleteModal } from "./delete-attachment-modal"; -import { TAttachmentOperations } from "./root"; - -type TAttachmentOperationsRemoveModal = Exclude; type TIssueAttachmentItemList = { workspaceSlug: string; + projectId: string; issueId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentHelpers; disabled?: boolean; }; export const IssueAttachmentItemList: FC = observer((props) => { - const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props; - const [isLoading, setIsLoading] = useState(false); - + const { workspaceSlug, projectId, issueId, attachmentHelpers, disabled } = props; + // states + const [isUploading, setIsUploading] = useState(false); // store hooks - const { config } = useInstance(); const { attachment: { getAttachmentsByIssueId }, attachmentDeleteModalId, toggleDeleteAttachmentModal, + fetchActivities, } = useIssueDetail(); + const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers; + const { create: createAttachment } = attachmentOperations; + const { uploadStatus } = attachmentSnapshot; + // file size + const { maxFileSize } = useFileSize(); // derived values const issueAttachments = getAttachmentsByIssueId(issueId); - const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; + // handlers + const handleFetchPropertyActivities = useCallback(() => { + fetchActivities(workspaceSlug, projectId, issueId); + }, [fetchActivities, workspaceSlug, projectId, issueId]); - if(rejectedFiles.length===0){ + const onDrop = useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; + + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, + setIsUploading(true); + createAttachment(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - ); - setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", - }) - }) - .finally(() => setIsLoading(false)); + .finally(() => { + handleFetchPropertyActivities(); + setIsUploading(false); + }); return; } setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, - disabled: isLoading || disabled, + disabled: isUploading || disabled, }); - if (!issueAttachments) return <>; - return ( <> - {attachmentDeleteModalId && ( - toggleDeleteAttachmentModal(null)} - handleAttachmentOperations={handleAttachmentOperations} - attachmentId={attachmentDeleteModalId} - /> - )} -
- - {isDragActive && ( -
-
-
- - Drag and drop anywhere to upload + {uploadStatus?.map((uploadStatus) => ( + + ))} + {issueAttachments && ( + <> + {attachmentDeleteModalId && ( + toggleDeleteAttachmentModal(null)} + attachmentOperations={attachmentOperations} + attachmentId={attachmentDeleteModalId} + /> + )} +
+ + {isDragActive && ( +
+
+
+ + Drag and drop anywhere to upload +
+
-
+ )} + {issueAttachments?.map((attachmentId) => ( + + ))}
- )} - {issueAttachments?.map((attachmentId) => ( - - ))} -
+ + )} ); }); diff --git a/web/core/components/issues/attachment/attachment-list-item.tsx b/web/core/components/issues/attachment/attachment-list-item.tsx index 28cff6995..e3adc5f82 100644 --- a/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/web/core/components/issues/attachment/attachment-list-item.tsx @@ -11,6 +11,7 @@ import { getFileIcon } from "@/components/icons"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -29,9 +30,12 @@ export const IssueAttachmentsListItem: FC = observer( attachment: { getAttachmentById }, toggleDeleteAttachmentModal, } = useIssueDetail(); - // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 18); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -43,17 +47,14 @@ export const IssueAttachmentsListItem: FC = observer( onClick={(e) => { e.preventDefault(); e.stopPropagation(); - window.open(attachment.asset, "_blank"); + window.open(fileURL, "_blank"); }} >
-
{getFileIcon(getFileExtension(attachment.asset), 18)}
- -

{`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}

+
{fileIcon}
+ +

{`${fileName}.${fileExtension}`}

{convertBytesToSize(attachment.attributes.size)} diff --git a/web/core/components/issues/attachment/attachment-list-upload-item.tsx b/web/core/components/issues/attachment/attachment-list-upload-item.tsx new file mode 100644 index 000000000..8ea9d3470 --- /dev/null +++ b/web/core/components/issues/attachment/attachment-list-upload-item.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { CircularProgressIndicator, Tooltip } from "@plane/ui"; +// components +import { getFileIcon } from "@/components/icons"; +// helpers +import { getFileExtension } from "@/helpers/attachment.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// types +import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; + +type Props = { + uploadStatus: TAttachmentUploadStatus; +}; + +export const IssueAttachmentsUploadItem: React.FC = observer((props) => { + // props + const { uploadStatus } = props; + // derived values + const fileName = uploadStatus.name; + const fileExtension = getFileExtension(uploadStatus.name ?? ""); + const fileIcon = getFileIcon(fileExtension, 18); + // hooks + const { isMobile } = usePlatformOS(); + + return ( +
+
+
{fileIcon}
+ +

{fileName}

+
+
+
+ + + +
{uploadStatus.progress}% done
+
+
+ ); +}); diff --git a/web/core/components/issues/attachment/attachment-upload-details.tsx b/web/core/components/issues/attachment/attachment-upload-details.tsx new file mode 100644 index 000000000..6d4eca96e --- /dev/null +++ b/web/core/components/issues/attachment/attachment-upload-details.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { CircularProgressIndicator, Tooltip } from "@plane/ui"; +// icons +import { getFileIcon } from "@/components/icons"; +// helpers +import { getFileExtension } from "@/helpers/attachment.helper"; +import { truncateText } from "@/helpers/string.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// types +import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; + +type Props = { + uploadStatus: TAttachmentUploadStatus; +}; + +export const IssueAttachmentsUploadDetails: React.FC = observer((props) => { + // props + const { uploadStatus } = props; + // derived values + const fileName = uploadStatus.name; + const fileExtension = getFileExtension(uploadStatus.name ?? ""); + const fileIcon = getFileIcon(fileExtension, 28); + // hooks + const { isMobile } = usePlatformOS(); + + return ( +
+
+
{fileIcon}
+
+
+ + {truncateText(`${fileName}`, 10)} + +
+ +
+ {fileExtension.toUpperCase()} +
+
+
+
+ + + +
{uploadStatus.progress}% done
+
+
+ ); +}); diff --git a/web/core/components/issues/attachment/attachment-upload.tsx b/web/core/components/issues/attachment/attachment-upload.tsx index 4be4cf11b..2de55ac00 100644 --- a/web/core/components/issues/attachment/attachment-upload.tsx +++ b/web/core/components/issues/attachment/attachment-upload.tsx @@ -1,62 +1,44 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helpers -import { generateFileName } from "@/helpers/attachment.helper"; -// hooks -import { useInstance } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // types -import { TAttachmentOperations } from "./root"; +import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; -type TAttachmentOperationsModal = Exclude; +type TAttachmentOperationsModal = Pick; type Props = { workspaceSlug: string; disabled?: boolean; - handleAttachmentOperations: TAttachmentOperationsModal; + attachmentOperations: TAttachmentOperationsModal; }; export const IssueAttachmentUpload: React.FC = observer((props) => { - const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; - // store hooks - const { config } = useInstance(); + const { workspaceSlug, disabled = false, attachmentOperations } = props; // states const [isLoading, setIsLoading] = useState(false); + // file size + const { maxFileSize } = useFileSize(); const onDrop = useCallback( (acceptedFiles: File[]) => { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + attachmentOperations.create(currentFile).finally(() => setIsLoading(false)); }, - [handleAttachmentOperations, workspaceSlug] + [attachmentOperations, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); - const maxFileSize = config?.file_size_limit ?? MAX_FILE_SIZE; - const fileError = fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null; diff --git a/web/core/components/issues/attachment/attachments-list.tsx b/web/core/components/issues/attachment/attachments-list.tsx index bff10047e..a25ee2589 100644 --- a/web/core/components/issues/attachment/attachments-list.tsx +++ b/web/core/components/issues/attachment/attachments-list.tsx @@ -2,38 +2,40 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks import { useIssueDetail } from "@/hooks/store"; +// types +import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentsDetail } from "./attachment-detail"; -// types -import { TAttachmentOperations } from "./root"; - -type TAttachmentOperationsRemoveModal = Exclude; +import { IssueAttachmentsUploadDetails } from "./attachment-upload-details"; type TIssueAttachmentsList = { issueId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentHelpers: TAttachmentHelpers; disabled?: boolean; }; export const IssueAttachmentsList: FC = observer((props) => { - const { issueId, handleAttachmentOperations, disabled } = props; + const { issueId, attachmentHelpers, disabled } = props; // store hooks const { attachment: { getAttachmentsByIssueId }, } = useIssueDetail(); // derived values + const { snapshot: attachmentSnapshot } = attachmentHelpers; + const { uploadStatus } = attachmentSnapshot; const issueAttachments = getAttachmentsByIssueId(issueId); - if (!issueAttachments) return <>; - return ( <> + {uploadStatus?.map((uploadStatus) => ( + + ))} {issueAttachments?.map((attachmentId) => ( ))} diff --git a/web/core/components/issues/attachment/delete-attachment-modal.tsx b/web/core/components/issues/attachment/delete-attachment-modal.tsx index 4c7984da2..925ff21c0 100644 --- a/web/core/components/issues/attachment/delete-attachment-modal.tsx +++ b/web/core/components/issues/attachment/delete-attachment-modal.tsx @@ -8,19 +8,19 @@ import { getFileName } from "@/helpers/attachment.helper"; // hooks import { useIssueDetail } from "@/hooks/store"; // types -import { TAttachmentOperations } from "./root"; +import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; -export type TAttachmentOperationsRemoveModal = Exclude; +export type TAttachmentOperationsRemoveModal = Pick; type Props = { isOpen: boolean; onClose: () => void; attachmentId: string; - handleAttachmentOperations: TAttachmentOperationsRemoveModal; + attachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = observer((props) => { - const { isOpen, onClose, attachmentId, handleAttachmentOperations } = props; + const { isOpen, onClose, attachmentId, attachmentOperations } = props; // states const [loader, setLoader] = useState(false); @@ -40,7 +40,7 @@ export const IssueAttachmentDeleteModal: FC = observer((props) => { const handleDeletion = async (assetId: string) => { setLoader(true); - handleAttachmentOperations.remove(assetId).finally(() => handleClose()); + attachmentOperations.remove(assetId).finally(() => handleClose()); }; if (!attachment) return <>; diff --git a/web/core/components/issues/attachment/index.ts b/web/core/components/issues/attachment/index.ts index 0f1c8a332..af20960a8 100644 --- a/web/core/components/issues/attachment/index.ts +++ b/web/core/components/issues/attachment/index.ts @@ -1,6 +1,8 @@ export * from "./attachment-detail"; export * from "./attachment-item-list"; export * from "./attachment-list-item"; +export * from "./attachment-list-upload-item"; +export * from "./attachment-upload-details"; export * from "./attachment-upload"; export * from "./attachments-list"; export * from "./delete-attachment-modal"; diff --git a/web/core/components/issues/attachment/root.tsx b/web/core/components/issues/attachment/root.tsx index f1bec92e8..700b35bb8 100644 --- a/web/core/components/issues/attachment/root.tsx +++ b/web/core/components/issues/attachment/root.tsx @@ -1,10 +1,9 @@ "use client"; -import { FC, useMemo } from "react"; +import { FC } from "react"; +import { observer } from "mobx-react"; // hooks -import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; -import { useEventTracker, useIssueDetail } from "@/hooks/store"; -// ui +import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentUpload } from "./attachment-upload"; import { IssueAttachmentsList } from "./attachments-list"; @@ -16,89 +15,11 @@ export type TIssueAttachmentRoot = { disabled?: boolean; }; -export type TAttachmentOperations = { - create: (data: FormData) => Promise; - remove: (linkId: string) => Promise; -}; - -export const IssueAttachmentRoot: FC = (props) => { +export const IssueAttachmentRoot: FC = observer((props) => { // props const { workspaceSlug, projectId, issueId, disabled = false } = props; // hooks - const { createAttachment, removeAttachment } = useIssueDetail(); - const { captureIssueEvent } = useEventTracker(); - - const handleAttachmentOperations: TAttachmentOperations = useMemo( - () => ({ - create: async (data: FormData) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); - setPromiseToast(attachmentUploadPromise, { - loading: "Uploading attachment...", - success: { - title: "Attachment uploaded", - message: () => "The attachment has been successfully uploaded", - }, - error: { - title: "Attachment not uploaded", - message: () => "The attachment could not be uploaded", - }, - }); - - const res = await attachmentUploadPromise; - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: res.id, - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - }); - } - }, - remove: async (attachmentId: string) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); - setToast({ - message: "The attachment has been successfully removed", - type: TOAST_TYPE.SUCCESS, - title: "Attachment removed", - }); - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - setToast({ - message: "The Attachment could not be removed", - type: TOAST_TYPE.ERROR, - title: "Attachment not removed", - }); - } - }, - }), - [captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment] - ); + const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId); return (
@@ -107,14 +28,10 @@ export const IssueAttachmentRoot: FC = (props) => { - +
); -}; +}); diff --git a/web/core/components/issues/confirm-issue-discard.tsx b/web/core/components/issues/confirm-issue-discard.tsx index 09bedbf59..580703490 100644 --- a/web/core/components/issues/confirm-issue-discard.tsx +++ b/web/core/components/issues/confirm-issue-discard.tsx @@ -61,10 +61,12 @@ export const ConfirmIssueDiscard: React.FC = (props) => {
- Draft Issue + Save this draft?
-

Would you like to save this issue in drafts?

+

+ You can save this issue to Drafts so you can come back to it later.{" "} +

@@ -80,7 +82,7 @@ export const ConfirmIssueDiscard: React.FC = (props) => { Cancel
diff --git a/web/core/components/issues/delete-issue-modal.tsx b/web/core/components/issues/delete-issue-modal.tsx index 2f61495ed..f19141234 100644 --- a/web/core/components/issues/delete-issue-modal.tsx +++ b/web/core/components/issues/delete-issue-modal.tsx @@ -2,19 +2,21 @@ import { useEffect, useState } from "react"; // types -import { TIssue } from "@plane/types"; +import { TDeDupeIssue, TIssue } from "@plane/types"; // ui import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; // hooks import { useIssues, useProject, useUser, useUserPermissions } from "@/hooks/store"; +// plane-web import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + type Props = { isOpen: boolean; handleClose: () => void; dataId?: string | null | undefined; - data?: TIssue; + data?: TIssue | TDeDupeIssue; isSubIssue?: boolean; onSubmit?: () => Promise; }; diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index 56819d006..8c18618c5 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -15,6 +16,9 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); export type IssueDescriptionInputProps = { containerClassName?: string; @@ -115,12 +119,31 @@ export const IssueDescriptionInput: FC = observer((p placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } containerClassName={containerClassName} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: issueId, + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ) : ( ) } diff --git a/web/core/components/issues/issue-detail-widgets/attachments/content.tsx b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx index f792af284..9c5b2a491 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/content.tsx @@ -1,5 +1,6 @@ "use client"; import React, { FC } from "react"; +import { observer } from "mobx-react"; // components import { IssueAttachmentItemList } from "@/components/issues/attachment"; // helper @@ -12,16 +13,17 @@ type Props = { disabled: boolean; }; -export const IssueAttachmentsCollapsibleContent: FC = (props) => { +export const IssueAttachmentsCollapsibleContent: FC = observer((props) => { const { workspaceSlug, projectId, issueId, disabled } = props; // helper - const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); + const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId); return ( ); -}; +}); diff --git a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index 539c9ea18..43b4812e6 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -1,26 +1,42 @@ "use client"; import { useMemo } from "react"; +// plane ui import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; -// type -import { TAttachmentOperations } from "@/components/issues/attachment"; // hooks import { useEventTracker, useIssueDetail } from "@/hooks/store"; +// types +import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; + +export type TAttachmentOperations = { + create: (file: File) => Promise; + remove: (attachmentId: string) => Promise; +}; + +export type TAttachmentSnapshot = { + uploadStatus: TAttachmentUploadStatus[] | undefined; +}; + +export type TAttachmentHelpers = { + operations: TAttachmentOperations; + snapshot: TAttachmentSnapshot; +}; export const useAttachmentOperations = ( workspaceSlug: string, projectId: string, issueId: string -): TAttachmentOperations => { - const { createAttachment, removeAttachment } = useIssueDetail(); +): TAttachmentHelpers => { + const { + attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId }, + } = useIssueDetail(); const { captureIssueEvent } = useEventTracker(); - const handleAttachmentOperations: TAttachmentOperations = useMemo( + const attachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (data: FormData) => { + create: async (file) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); setPromiseToast(attachmentUploadPromise, { loading: "Uploading attachment...", success: { @@ -47,9 +63,10 @@ export const useAttachmentOperations = ( eventName: "Issue attachment added", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, }); + throw error; } }, - remove: async (attachmentId: string) => { + remove: async (attachmentId) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); @@ -83,8 +100,12 @@ export const useAttachmentOperations = ( } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] + [captureIssueEvent, workspaceSlug, projectId, issueId, createAttachment, removeAttachment] ); + const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId); - return handleAttachmentOperations; + return { + operations: attachmentOperations, + snapshot: { uploadStatus: attachmentsUploadStatus }, + }; }; diff --git a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index 01923b210..c2d88a954 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -1,15 +1,15 @@ "use client"; + import React, { FC, useCallback, useState } from "react"; import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { Plus } from "lucide-react"; -import {TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helper -import { generateFileName } from "@/helpers/attachment.helper"; +// plane ui +import { TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; import { useAttachmentOperations } from "./helper"; @@ -26,73 +26,73 @@ export const IssueAttachmentActionButton: FC = observer((props) => { // state const [isLoading, setIsLoading] = useState(false); // store hooks - const { config } = useInstance(); - const { setLastWidgetAction } = useIssueDetail(); - + const { setLastWidgetAction, fetchActivities } = useIssueDetail(); + // file size + const { maxFileSize } = useFileSize(); // operations - const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); - + const { operations: attachmentOperations } = useAttachmentOperations(workspaceSlug, projectId, issueId); // handlers + const handleFetchPropertyActivities = useCallback(() => { + fetchActivities(workspaceSlug, projectId, issueId); + }, [fetchActivities, workspaceSlug, projectId, issueId]); + const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - if(rejectedFiles.length===0){ + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", + attachmentOperations + .create(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - }) - .finally(() => { - setLastWidgetAction("attachments"); - setIsLoading(false); - }); - return; + .finally(() => { + handleFetchPropertyActivities(); + setLastWidgetAction("attachments"); + setIsLoading(false); + }); + return; } setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction] ); - const { getRootProps, getInputProps } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); return ( - +
{ + // TODO: Remove extra div and move event propagation to button + e.stopPropagation(); + }} + > + +
); -}); \ No newline at end of file +}); diff --git a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx index 58018c13b..8537020b7 100644 --- a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx +++ b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -10,6 +10,8 @@ import { } from "@/components/issues/issue-detail-widgets"; // hooks import { useIssueDetail } from "@/hooks/store"; +// Plane-web +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; type Props = { workspaceSlug: string; @@ -24,19 +26,23 @@ export const IssueDetailWidgetCollapsibles: FC = observer((props) => { const { issue: { getIssueById }, subIssues: { subIssuesByIssueId }, - relation: { getRelationsByIssueId }, + attachment: { getAttachmentsUploadStatusByIssueId }, + relation: { getRelationCountByIssueId }, } = useIssueDetail(); // derived values const issue = getIssueById(issueId); const subIssues = subIssuesByIssueId(issueId); - const issueRelations = getRelationsByIssueId(issueId); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); + const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS); // render conditions const shouldRenderSubIssues = !!subIssues && subIssues.length > 0; - const shouldRenderRelations = Object.values(issueRelations ?? {}).some((relation) => relation.length > 0); + const shouldRenderRelations = issueRelationsCount > 0; const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0; - const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0; + const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId); + const shouldRenderAttachments = + (!!issue?.attachment_count && issue?.attachment_count > 0) || (!!attachmentUploads && attachmentUploads.length > 0); return (
diff --git a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx index cc46b80c3..fa9aa9d65 100644 --- a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx +++ b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx @@ -151,6 +151,7 @@ export const IssueDetailWidgetModals: FC = observer((props) => { data={createUpdateModalData} onClose={handleCreateUpdateModalClose} onSubmit={handleCreateUpdateModalOnSubmit} + isProjectSelectionDisabled /> )} @@ -162,7 +163,6 @@ export const IssueDetailWidgetModals: FC = observer((props) => { handleClose={handleExistingIssuesModalClose} searchParams={existingIssuesModalSearchParams} handleOnSubmit={handleExistingIssuesModalOnSubmit} - workspaceLevelToggle /> )} diff --git a/web/core/components/issues/issue-detail-widgets/links/helper.tsx b/web/core/components/issues/issue-detail-widgets/links/helper.tsx index f0a6ed316..4669528cd 100644 --- a/web/core/components/issues/issue-detail-widgets/links/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/links/helper.tsx @@ -27,6 +27,7 @@ export const useLinkOperations = (workspaceSlug: string, projectId: string, issu type: TOAST_TYPE.ERROR, title: "Link not created", }); + throw error; } }, update: async (linkId: string, data: Partial) => { @@ -44,6 +45,7 @@ export const useLinkOperations = (workspaceSlug: string, projectId: string, issu type: TOAST_TYPE.ERROR, title: "Link not updated", }); + throw error; } }, remove: async (linkId: string) => { diff --git a/web/core/components/issues/issue-detail-widgets/relations/content.tsx b/web/core/components/issues/issue-detail-widgets/relations/content.tsx index b078c19d1..79be48e4f 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/content.tsx @@ -1,15 +1,17 @@ "use client"; import { FC, useState } from "react"; import { observer } from "mobx-react"; -import { CircleDot, CopyPlus, XCircle } from "lucide-react"; import { TIssue, TIssueRelationIdMap } from "@plane/types"; -import { Collapsible, RelatedIcon } from "@plane/ui"; +import { Collapsible } from "@plane/ui"; // components import { RelationIssueList } from "@/components/issues"; import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; import { CreateUpdateIssueModal } from "@/components/issues/issue-modal"; // hooks import { useIssueDetail } from "@/hooks/store"; +// Plane-web +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; +import { TIssueRelationTypes } from "@/plane-web/types"; // helper import { useRelationOperations } from "./helper"; @@ -20,35 +22,16 @@ type Props = { disabled: boolean; }; -const ISSUE_RELATION_OPTIONS = [ - { - key: "blocked_by", - label: "Blocked by", - icon: (size: number) => , - className: "bg-red-500/20 text-red-700", - }, - { - key: "blocking", - label: "Blocking", - icon: (size: number) => , - className: "bg-yellow-500/20 text-yellow-700", - }, - { - key: "relates_to", - label: "Relates to", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, - { - key: "duplicate", - label: "Duplicate of", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, -]; - type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined }; +export type TRelationObject = { + key: TIssueRelationTypes; + label: string; + className: string; + icon: (size: number) => React.ReactElement; + placeholder: string; +}; + export const RelationsCollapsibleContent: FC = observer((props) => { const { workspaceSlug, projectId, issueId, disabled = false } = props; // state @@ -80,6 +63,7 @@ export const RelationsCollapsibleContent: FC = observer((props) => { // derived values const relations = getRelationsByIssueId(issueId); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); const handleIssueCrudState = (key: "update" | "delete", _issueId: string | null, issue: TIssue | null = null) => { setIssueCrudState({ @@ -96,17 +80,19 @@ export const RelationsCollapsibleContent: FC = observer((props) => { if (!relations) return null; // map relations to array - const relationsArray = Object.keys(relations).map((relationKey) => { - const issueIds = relations[relationKey as keyof TIssueRelationIdMap]; - const issueRelationOption = ISSUE_RELATION_OPTIONS.find((option) => option.key === relationKey); - return { - relationKey: relationKey as keyof TIssueRelationIdMap, - issueIds: issueIds, - icon: issueRelationOption?.icon, - label: issueRelationOption?.label, - className: issueRelationOption?.className, - }; - }); + const relationsArray = (Object.keys(relations) as TIssueRelationTypes[]) + .filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey]) + .map((relationKey) => { + const issueIds = relations[relationKey]; + const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey]; + return { + relationKey: relationKey, + issueIds: issueIds, + icon: issueRelationOption?.icon, + label: issueRelationOption?.label, + className: issueRelationOption?.className, + }; + }); // filter out relations with no issues const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0); diff --git a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx index fe3be8ca4..ac8b0f663 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx @@ -1,9 +1,8 @@ "use client"; import { useMemo } from "react"; import { usePathname } from "next/navigation"; -import { CircleDot, CopyPlus, XCircle } from "lucide-react"; import { TIssue } from "@plane/types"; -import { RelatedIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; // constants import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker"; // helper @@ -91,30 +90,3 @@ export const useRelationOperations = (): TRelationIssueOperations => { return issueOperations; }; - -export const ISSUE_RELATION_OPTIONS = [ - { - key: "blocked_by", - label: "Blocked by", - icon: (size: number) => , - className: "bg-red-500/20 text-red-700", - }, - { - key: "blocking", - label: "Blocking", - icon: (size: number) => , - className: "bg-yellow-500/20 text-yellow-700", - }, - { - key: "relates_to", - label: "Relates to", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, - { - key: "duplicate", - label: "Duplicate of", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, -]; diff --git a/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx index 67161ecbd..dff072e7d 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx @@ -2,12 +2,12 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; import { Plus } from "lucide-react"; -import { TIssueRelationTypes } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store"; -// helper -import { ISSUE_RELATION_OPTIONS } from "./helper"; +// Plane-web +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; +import { TIssueRelationTypes } from "@/plane-web/types"; type Props = { issueId: string; @@ -20,6 +20,8 @@ export const RelationActionButton: FC = observer((props) => { // store hooks const { toggleRelationModal, setRelationKey } = useIssueDetail(); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); + // handlers const handleOnClick = (relationKey: TIssueRelationTypes) => { setRelationKey(relationKey); @@ -30,22 +32,32 @@ export const RelationActionButton: FC = observer((props) => { const customButtonElement = customButton ? <>{customButton} : ; return ( - - {ISSUE_RELATION_OPTIONS.map((item, index) => ( - { - e.preventDefault(); - e.stopPropagation(); - handleOnClick(item.key as TIssueRelationTypes); - }} - > -
- {item.icon(12)} - {item.label} -
-
- ))} + + {Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => { + if (!item) return <>; + + return ( + { + e.preventDefault(); + e.stopPropagation(); + handleOnClick(item.key as TIssueRelationTypes); + }} + > +
+ {item.icon(12)} + {item.label} +
+
+ ); + })}
); }); diff --git a/web/core/components/issues/issue-detail-widgets/relations/title.tsx b/web/core/components/issues/issue-detail-widgets/relations/title.tsx index 8c6de6df8..2c3854bed 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/title.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/title.tsx @@ -6,6 +6,8 @@ import { CollapsibleButton } from "@plane/ui"; import { RelationActionButton } from "@/components/issues/issue-detail-widgets"; // hooks import { useIssueDetail } from "@/hooks/store"; +// Plane-web +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; type Props = { isOpen: boolean; @@ -17,12 +19,12 @@ export const RelationsCollapsibleTitle: FC = observer((props) => { const { isOpen, issueId, disabled } = props; // store hook const { - relation: { getRelationsByIssueId }, + relation: { getRelationCountByIssueId }, } = useIssueDetail(); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); // derived values - const issueRelations = getRelationsByIssueId(issueId); - const relationsCount = Object.values(issueRelations ?? {}).reduce((acc, curr) => acc + curr.length, 0); + const relationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS); // indicator element const indicatorElement = useMemo( diff --git a/web/core/components/issues/issue-detail-widgets/root.tsx b/web/core/components/issues/issue-detail-widgets/root.tsx index 364dac960..040fd2c2f 100644 --- a/web/core/components/issues/issue-detail-widgets/root.tsx +++ b/web/core/components/issues/issue-detail-widgets/root.tsx @@ -12,10 +12,11 @@ type Props = { projectId: string; issueId: string; disabled: boolean; + renderWidgetModals?: boolean; }; export const IssueDetailWidgets: FC = (props) => { - const { workspaceSlug, projectId, issueId, disabled } = props; + const { workspaceSlug, projectId, issueId, disabled, renderWidgetModals = true } = props; return ( <>
@@ -32,7 +33,9 @@ export const IssueDetailWidgets: FC = (props) => { disabled={disabled} />
- + {renderWidgetModals && ( + + )} ); }; diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx index aea04a5fe..d1ceb8f88 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx @@ -1,13 +1,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { TIssueRelationTypes } from "@plane/types"; // hooks -import { issueRelationObject } from "@/components/issues/issue-detail/relation-select"; import { useIssueDetail } from "@/hooks/store"; -// components +// Plane-web +import { getRelationActivityContent, useTimeLineRelationOptions } from "@/plane-web/components/relations"; +import { TIssueRelationTypes } from "@/plane-web/types"; +// import { IssueActivityBlockComponent } from "./"; -// component helpers -// types type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined }; @@ -19,32 +18,22 @@ export const IssueRelationActivity: FC = observer((props } = useIssueDetail(); const activity = getActivityById(activityId); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); + const activityContent = getRelationActivityContent(activity); if (!activity) return <>; return ( } + icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes]?.icon(14) : <>} activityId={activityId} ends={ends} > - <> - {activity.field === "blocking" && - (activity.old_value === "" ? `marked this issue is blocking issue ` : `removed the blocking issue `)} - {activity.field === "blocked_by" && - (activity.old_value === "" - ? `marked this issue is being blocked by ` - : `removed this issue being blocked by issue `)} - {activity.field === "duplicate" && - (activity.old_value === "" ? `marked this issue as duplicate of ` : `removed this issue as a duplicate of `)} - {activity.field === "relates_to" && - (activity.old_value === "" ? `marked that this issue relates to ` : `removed the relation from `)} - - {activity.old_value === "" ? ( - {activity.new_value}. - ) : ( - {activity.old_value}. - )} - + {activityContent} + {activity.old_value === "" ? ( + {activity.new_value}. + ) : ( + {activity.old_value}. + )} ); }); diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx index 18cd3481f..148cf1f26 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx @@ -1,9 +1,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; +// helpers +import { getValidKeysFromObject } from "@/helpers/array.helper"; // hooks import { useIssueDetail } from "@/hooks/store"; // plane web components import { IssueTypeActivity } from "@/plane-web/components/issues/issue-details"; +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; // local components import { IssueDefaultActivity, @@ -38,6 +41,8 @@ export const IssueActivityItem: FC = observer((props) => { activity: { getActivityById }, comment: {}, } = useIssueDetail(); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); + const activityRelations = getValidKeysFromObject(ISSUE_RELATION_OPTIONS); const componentDefaultProps = { activityId, ends }; @@ -59,7 +64,7 @@ export const IssueActivityItem: FC = observer((props) => { return ; case "parent": return ; - case ["blocking", "blocked_by", "duplicate", "relates_to"].find((field) => field === activityField): + case activityRelations.find((field) => field === activityField): return ; case "start_date": return ; diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx index 2fb7116ff..8b9a3eff0 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx @@ -1,10 +1,11 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import { MessageCircle } from "lucide-react"; -// hooks -import { calculateTimeAgo } from "@/helpers/date-time.helper"; -import { useIssueDetail } from "@/hooks/store"; // helpers +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useIssueDetail } from "@/hooks/store"; type TIssueCommentBlock = { commentId: string; @@ -27,9 +28,9 @@ export const IssueCommentBlock: FC = observer((props) => {
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( { = observer((props) => { }; useEffect(() => { - isEditing && setFocus("comment_html"); + if (isEditing) { + setFocus("comment_html"); + } }, [isEditing, setFocus]); const commentHTML = watch("comment_html"); @@ -155,6 +157,10 @@ export const IssueCommentCard: FC = observer((props) => { } }} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id); + return asset_id; + }} />
@@ -189,7 +195,13 @@ export const IssueCommentCard: FC = observer((props) => { )}
)} - + = (props) => { const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); // refs - const editorRef = useRef(null); + const editorRef = useRef(null); // store hooks const workspaceStore = useWorkspace(); const { peekIssue } = useIssueDetail(); @@ -44,13 +51,24 @@ export const IssueCommentCreate: FC = (props) => { }, }); - const onSubmit = async (formData: Partial) => - await activityOperations.createComment(formData).finally(() => { - reset({ - comment_html: "

", + const onSubmit = async (formData: Partial) => { + await activityOperations + .createComment(formData) + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } + }) + .finally(() => { + reset({ + comment_html: "

", + }); + editorRef.current?.clearEditor(); }); - editorRef.current?.clearEditor(); - }); + }; const commentHTML = watch("comment_html"); const isEmpty = isCommentEmpty(commentHTML); @@ -92,6 +110,11 @@ export const IssueCommentCreate: FC = (props) => { handleAccessChange={onAccessChange} showAccessSpecifier={showAccessSpecifier} isSubmitting={isSubmitting} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/web/core/components/issues/issue-detail/issue-activity/index.ts b/web/core/components/issues/issue-detail/issue-activity/index.ts index fd2b70985..e6d5ce077 100644 --- a/web/core/components/issues/issue-detail/issue-activity/index.ts +++ b/web/core/components/issues/issue-detail/issue-activity/index.ts @@ -8,3 +8,6 @@ export * from "./activity-filter"; // issue comment export * from "./comments"; + +// sort +export * from "./sort-root"; diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 8bb15b973..38f6b01c6 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -1,20 +1,25 @@ "use client"; -import { FC, Fragment, useMemo, useState } from "react"; +import { FC, useMemo, useState } from "react"; import { observer } from "mobx-react"; // types -import { TIssueComment } from "@plane/types"; +import { TFileSignedURLResponse, TIssueComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { IssueCommentCreate } from "@/components/issues"; -import { IssueActivityCommentRoot } from "@/components/issues/issue-detail"; +import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail"; // hooks -import { useIssueDetail, useProject } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store"; // plane web components import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog"; // plane web constants import { TActivityFilters, defaultActivityFilters } from "@/plane-web/constants/issues"; +import { EUserPermissions } from "@/plane-web/constants/user-permissions"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TIssueActivity = { workspaceSlug: string; @@ -25,18 +30,34 @@ type TIssueActivity = { }; export type TActivityOperations = { - createComment: (data: Partial) => Promise; + createComment: (data: Partial) => Promise; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; + uploadCommentAsset: (file: File, commentId?: string) => Promise; }; export const IssueActivity: FC = observer((props) => { const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props; - // hooks - const { createComment, updateComment, removeComment } = useIssueDetail(); - const { getProjectById } = useProject(); // state const [selectedFilters, setSelectedFilters] = useState(defaultActivityFilters); + // hooks + const { + issue: { getIssueById }, + activity: { sortOrder, toggleSortOrder }, + createComment, + updateComment, + removeComment, + } = useIssueDetail(); + const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions(); + const { getProjectById } = useProject(); + const { data: currentUser } = useUser(); + //derived values + const issue = issueId ? getIssueById(issueId) : undefined; + const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId); + const isAdmin = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.ADMIN; + const isGuest = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.GUEST; + const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false; + const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned); // toggle filter const toggleFilter = (filter: TActivityFilters) => { setSelectedFilters((prevFilters) => { @@ -51,15 +72,16 @@ export const IssueActivity: FC = observer((props) => { const activityOperations: TActivityOperations = useMemo( () => ({ - createComment: async (data: Partial) => { + createComment: async (data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - await createComment(workspaceSlug, projectId, issueId, data); + const comment = await createComment(workspaceSlug, projectId, issueId, data); setToast({ title: "Success!", type: TOAST_TYPE.SUCCESS, message: "Comment created successfully.", }); + return comment; } catch (error) { setToast({ title: "Error!", @@ -68,7 +90,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - updateComment: async (commentId: string, data: Partial) => { + updateComment: async (commentId, data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await updateComment(workspaceSlug, projectId, issueId, commentId, data); @@ -85,7 +107,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - removeComment: async (commentId: string) => { + removeComment: async (commentId) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await removeComment(workspaceSlug, projectId, issueId, commentId); @@ -102,6 +124,24 @@ export const IssueActivity: FC = observer((props) => { }); } }, + uploadCommentAsset: async (file, commentId) => { + try { + if (!workspaceSlug || !projectId) throw new Error("Missing fields"); + const res = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: commentId ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }, }), [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment] ); @@ -115,7 +155,7 @@ export const IssueActivity: FC = observer((props) => {
Activity
- {!isIntakeIssue && ( + {isWorklogButtonEnabled && ( = observer((props) => { disabled={disabled} /> )} + void; +}; +export const ActivitySortRoot: FC = memo((props) => ( +
{ + props.toggleSort(); + }} + > + {props.sortOrder === "asc" ? ( + + ) : ( + + )} +
+)); + +ActivitySortRoot.displayName = "ActivitySortRoot"; diff --git a/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx b/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx index ab0d80f1b..99e350c61 100644 --- a/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx +++ b/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx @@ -7,8 +7,6 @@ import { Controller, useForm } from "react-hook-form"; import type { TIssueLinkEditableFields } from "@plane/types"; // plane ui import { Button, Input, ModalCore } from "@plane/ui"; -// helpers -import { checkURLValidity } from "@/helpers/string.helper"; // hooks import { useIssueDetail } from "@/hooks/store"; // types @@ -48,14 +46,18 @@ export const IssueLinkCreateUpdateModal: FC = observe const onClose = () => { setIssueLinkData(null); - reset(); if (handleOnClose) handleOnClose(); }; const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => { - if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: formData.url }); - else await linkOperations.update(formData.id as string, { title: formData.title, url: formData.url }); - onClose(); + const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`; + try { + if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl }); + else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl }); + onClose(); + } catch (error) { + console.error("error", error); + } }; useEffect(() => { @@ -77,7 +79,6 @@ export const IssueLinkCreateUpdateModal: FC = observe name="url" rules={{ required: "URL is required", - validate: (value) => checkURLValidity(value) || "URL is invalid", }} render={({ field: { value, onChange, ref } }) => ( = observer((props) => { <>
-
- +
+ { copyTextToClipboard(linkDetail.url); setToast({ @@ -60,21 +60,21 @@ export const IssueLinkItem: FC = observer((props) => {
-
-

+

+

{calculateTimeAgoShort(linkDetail.created_at)}

= observer((props) => { if (!issueLinks) return null; return ( -
+
{issueLinks.map((linkId) => ( ))} diff --git a/web/core/components/issues/issue-detail/links/root.tsx b/web/core/components/issues/issue-detail/links/root.tsx index 7b1908c35..b2fb4fb55 100644 --- a/web/core/components/issues/issue-detail/links/root.tsx +++ b/web/core/components/issues/issue-detail/links/root.tsx @@ -58,6 +58,7 @@ export const IssueLinkRoot: FC = (props) => { type: TOAST_TYPE.ERROR, title: "Link not created", }); + throw error; } }, update: async (linkId: string, data: Partial) => { @@ -76,6 +77,7 @@ export const IssueLinkRoot: FC = (props) => { type: TOAST_TYPE.ERROR, title: "Link not updated", }); + throw error; } }, remove: async (linkId: string) => { diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 2bb16f515..fb4dbc1fc 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -13,12 +13,16 @@ import { IssueDetailWidgets, PeekOverviewProperties, } from "@/components/issues"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; // hooks -import { useIssueDetail, useUser } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useSize from "@/hooks/use-window-size"; // plane web components +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // types import { TIssueOperations } from "./root"; @@ -40,9 +44,22 @@ export const IssueMainContent: React.FC = observer((props) => { const { data: currentUser } = useUser(); const { issue: { getIssueById }, + peekIssue, } = useIssueDetail(); + const { getProjectById } = useProject(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + // derived values + const projectDetails = getProjectById(projectId); + const issue = issueId ? getIssueById(issueId) : undefined; + + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectDetails?.id, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + useEffect(() => { if (isSubmitting === "submitted") { setShowAlert(false); @@ -50,9 +67,10 @@ export const IssueMainContent: React.FC = observer((props) => { } else if (isSubmitting === "submitting") setShowAlert(true); }, [isSubmitting, setShowAlert, setIsSubmitting]); - const issue = issueId ? getIssueById(issueId) : undefined; if (!issue || !issue.project_id) return <>; + const isPeekModeActive = Boolean(peekIssue); + return ( <>
@@ -68,7 +86,19 @@ export const IssueMainContent: React.FC = observer((props) => {
- +
+ + {duplicateIssues?.length > 0 && ( + + )} +
= observer((props) => { containerClassName="-ml-3" /> - {/* {issue?.description_html === issueDescription && ( */} = observer((props) => { setIsSubmitting={(value) => setIsSubmitting(value)} containerClassName="-ml-3 border-none" /> - {/* )} */} {currentUser && ( = observer((props) => { projectId={projectId} issueId={issueId} disabled={!isEditable || isArchived} + renderWidgetModals={!isPeekModeActive} /> {windowSize[0] < 768 && ( diff --git a/web/core/components/issues/issue-detail/parent/root.tsx b/web/core/components/issues/issue-detail/parent/root.tsx index edf80814c..c96ecd69a 100644 --- a/web/core/components/issues/issue-detail/parent/root.tsx +++ b/web/core/components/issues/issue-detail/parent/root.tsx @@ -2,14 +2,15 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { MinusCircle } from "lucide-react"; import { TIssue } from "@plane/types"; // component // ui -import { CustomMenu } from "@plane/ui"; +import { ControlLink, CustomMenu } from "@plane/ui"; // hooks import { useIssues, useProjectState } from "@/hooks/store"; +import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection"; +import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; // types @@ -29,6 +30,8 @@ export const IssueParentDetail: FC = observer((props) => { // hooks const { issueMap } = useIssues(); const { getProjectStates } = useProjectState(); + const { handleRedirection } = useIssuePeekOverviewRedirection(); + const { isMobile } = usePlatformOS(); const parentIssue = issueMap?.[issue.parent_id || ""] || undefined; @@ -42,7 +45,10 @@ export const IssueParentDetail: FC = observer((props) => { return ( <>
- + handleRedirection(workspaceSlug, parentIssue, isMobile)} + >
@@ -56,7 +62,7 @@ export const IssueParentDetail: FC = observer((props) => {
{(parentIssue?.name ?? "").substring(0, 50)}
- +
diff --git a/web/core/components/issues/issue-detail/parent/sibling-item.tsx b/web/core/components/issues/issue-detail/parent/sibling-item.tsx index da7adefa9..8fb9bbd9a 100644 --- a/web/core/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/core/components/issues/issue-detail/parent/sibling-item.tsx @@ -33,6 +33,7 @@ export const IssueParentSiblingItem: FC = observer((pro {issueDetail.project_id && projectDetails?.identifier && ( diff --git a/web/core/components/issues/issue-detail/relation-select.tsx b/web/core/components/issues/issue-detail/relation-select.tsx index c787c5c64..1796f5454 100644 --- a/web/core/components/issues/issue-detail/relation-select.tsx +++ b/web/core/components/issues/issue-detail/relation-select.tsx @@ -4,42 +4,21 @@ import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { CircleDot, CopyPlus, Pencil, X, XCircle } from "lucide-react"; -import { TIssueRelationTypes, ISearchIssueResponse } from "@plane/types"; -// hooks +// Plane +import { ISearchIssueResponse } from "@plane/types"; import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { ExistingIssuesListModal } from "@/components/core"; +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useIssueDetail, useIssues, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// components -// ui -// helpers -// types - -export type TRelationObject = { className: string; icon: (size: number) => React.ReactElement; placeholder: string }; - -export const issueRelationObject: Record = { - relates_to: { - className: "bg-custom-background-80 text-custom-text-200", - icon: (size) => , - placeholder: "Add related issues", - }, - blocking: { - className: "bg-yellow-500/20 text-yellow-700", - icon: (size) => , - placeholder: "None", - }, - blocked_by: { - className: "bg-red-500/20 text-red-700", - icon: (size) => , - placeholder: "None", - }, - duplicate: { - className: "bg-custom-background-80 text-custom-text-200", - icon: (size) => , - placeholder: "None", - }, -}; +// Plane-web +import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; +import { TIssueRelationTypes } from "@/plane-web/types"; +// +import { TRelationObject } from "../issue-detail-widgets"; type TIssueRelationSelect = { className?: string; @@ -64,6 +43,7 @@ export const IssueRelationSelect: React.FC = observer((pro const { issueMap } = useIssues(); const { isMobile } = usePlatformOS(); const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey); + const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); const onSubmit = async (data: ISearchIssueResponse[]) => { if (data.length === 0) { @@ -91,6 +71,8 @@ export const IssueRelationSelect: React.FC = observer((pro const isRelationKeyModalActive = isRelationModalOpen?.relationType === relationKey && isRelationModalOpen?.issueId === issueId; + const currRelationOption: TRelationObject | undefined = ISSUE_RELATION_OPTIONS[relationKey]; + return ( <> = observer((pro return (
= observer((pro })}
) : ( - {issueRelationObject[relationKey].placeholder} + {currRelationOption?.placeholder} )} {!disabled && ( = observer((props) => { try { await fetchIssue(workspaceSlug, projectId, issueId); } catch (error) { - console.error("Error fetching the parent issue"); + console.error("Error fetching the parent issue:", error); } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { @@ -101,6 +101,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in updating issue:", error); captureIssueEvent({ eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, @@ -132,6 +133,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in deleting issue:", error); setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -153,6 +155,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in archiving issue:", error); captureIssueEvent({ eventName: ISSUE_ARCHIVED, payload: { id: issueId, state: "FAILED", element: "Issue details page" }, @@ -318,6 +321,7 @@ export const IssueDetailRoot: FC = observer((props) => { archiveIssue, removeArchivedIssue, addIssueToCycle, + addCycleToIssue, removeIssueFromCycle, changeModulesInIssue, removeIssueFromModule, @@ -356,7 +360,7 @@ export const IssueDetailRoot: FC = observer((props) => { />
= o if (!workspaceSlug || !projectId) return; const issueIds = data.map((i) => i.id); + const addExistingIssuesPromise = Promise.all( + data.map((issue) => updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {})) + ).then(() => addIssuesToView?.(issueIds)); - try { - // To handle all updates in parallel - await Promise.all( - data.map((issue) => - updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {}) - ) - ); - await addIssuesToView?.(issueIds); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, + setPromiseToast(addExistingIssuesPromise, { + loading: `Adding ${issueIds.length > 1 ? "issues" : "issue"} to cycle...`, + success: { + title: "Success!", + message: () => `${issueIds.length > 1 ? "Issues" : "Issue"} added to cycle successfully.`, + }, + error: { title: "Error!", - message: "Something went wrong. Please try again.", - }); - } + message: (err) => err?.message || "Something went wrong. Please try again.", + }, + }); }; const handleNewIssue = () => { @@ -130,4 +129,4 @@ export const CalendarQuickAddIssueActions: FC = o /> ); -}); +}); \ No newline at end of file diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx index 7f71abe7d..ed0b6a154 100644 --- a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && ( - } - placement="bottom-start" - tabIndex={getIndex("parent_id")} - > - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - ( - { - onChange(null); - handleFormChange(); - }} - > - Remove parent issue - - )} - /> - - - ) : ( - - )} +
+ {parentId ? ( + + {selectedParentIssue?.project_id && ( + + )} + + } + placement="bottom-start" + className="h-full w-full" + customButtonClassName="h-full" + tabIndex={getIndex("parent_id")} + > + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + ( + { + onChange(null); + handleFormChange(); + }} + > + Remove parent issue + + )} + /> + + + ) : ( + + )} +
; + isDraft: boolean; issueName: string; + issueId: string | undefined; descriptionHtmlData: string | undefined; editorRef: React.MutableRefObject; submitBtnRef: React.MutableRefObject; @@ -38,16 +42,20 @@ type TIssueDescriptionEditorProps = { handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void; setGptAssistantModal: React.Dispatch>; handleGptAssistantClose: () => void; + onAssetUpload: (assetId: string) => void; onClose: () => void; }; // services const aiService = new AIService(); +const fileService = new FileService(); export const IssueDescriptionEditor: React.FC = observer((props) => { const { control, + isDraft, issueName, + issueId, descriptionHtmlData, editorRef, submitBtnRef, @@ -58,6 +66,7 @@ export const IssueDescriptionEditor: React.FC = ob handleDescriptionHTMLDataChange, setGptAssistantModal, handleGptAssistantClose, + onAssetUpload, onClose, } = props; // states @@ -180,6 +189,26 @@ export const IssueDescriptionEditor: React.FC = ob tabIndex={getIndex("description_html")} placeholder={getDescriptionPlaceholder} containerClassName="pt-3 min-h-[120px]" + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: issueId ?? "", + entity_type: isDraft + ? EFileAssetType.DRAFT_ISSUE_DESCRIPTION + : EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + onAssetUpload(asset_id); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> )} /> @@ -226,6 +255,8 @@ export const IssueDescriptionEditor: React.FC = ob AI } + workspaceSlug={workspaceSlug} + projectId={projectId} /> )}
diff --git a/web/core/components/issues/issue-modal/context/issue-modal.tsx b/web/core/components/issues/issue-modal/context/issue-modal.tsx index 845aec552..8181445a4 100644 --- a/web/core/components/issues/issue-modal/context/issue-modal.tsx +++ b/web/core/components/issues/issue-modal/context/issue-modal.tsx @@ -21,6 +21,8 @@ export type TCreateUpdatePropertyValuesProps = { issueId: string; projectId: string; workspaceSlug: string; + issueTypeId: string | null | undefined; + isDraft?: boolean; }; export type TIssueModalContext = { diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx index 49bb1734d..d74cb7606 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -14,9 +14,7 @@ import { ConfirmIssueDiscard } from "@/components/issues"; import { isEmptyHtmlString } from "@/helpers/string.helper"; // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; -import { useEventTracker } from "@/hooks/store"; -// services -import { IssueDraftService } from "@/services/issue"; +import { useEventTracker, useWorkspaceDraftIssues } from "@/hooks/store"; // local components import { IssueFormRoot } from "./form"; @@ -25,21 +23,30 @@ export interface DraftIssueProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange: (formData: Partial | null) => void; onClose: (saveDraftIssueInLocalStorage?: boolean) => void; onSubmit: (formData: Partial, is_draft_issue?: boolean) => Promise; projectId: string; isDraft: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (isOpen: boolean) => void; + isProjectSelectionDisabled?: boolean; } -const issueDraftService = new IssueDraftService(); - export const DraftIssueLayout: React.FC = observer((props) => { const { changesMade, data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -47,6 +54,12 @@ export const DraftIssueLayout: React.FC = observer((props) => { isCreateMoreToggleEnabled, onCreateMoreToggleChange, isDraft, + moveToIssue = false, + modalTitle, + primaryButtonText, + isDuplicateModalOpen, + handleDuplicateIssueModal, + isProjectSelectionDisabled = false, } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); @@ -57,6 +70,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { // store hooks const { captureIssueEvent } = useEventTracker(); const { handleCreateUpdatePropertyValues } = useIssueModal(); + const { createIssue } = useWorkspaceDraftIssues(); const handleClose = () => { if (data?.id) { @@ -95,15 +109,15 @@ export const DraftIssueLayout: React.FC = observer((props) => { const payload = { ...changesMade, name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled", + project_id: projectId, }; - const response = await issueDraftService - .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + const response = await createIssue(workspaceSlug.toString(), payload) .then((res) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", - message: "Draft Issue created successfully.", + message: "Draft created.", }); captureIssueEvent({ eventName: "Draft issue created", @@ -131,8 +145,10 @@ export const DraftIssueLayout: React.FC = observer((props) => { if (response && handleCreateUpdatePropertyValues) { handleCreateUpdatePropertyValues({ issueId: response.id, + issueTypeId: response.type_id, projectId, workspaceSlug: workspaceSlug?.toString(), + isDraft: true, }); } }; @@ -154,11 +170,18 @@ export const DraftIssueLayout: React.FC = observer((props) => { onCreateMoreToggleChange={onCreateMoreToggleChange} data={data} issueTitleRef={issueTitleRef} + onAssetUpload={onAssetUpload} onChange={onChange} onClose={handleClose} onSubmit={onSubmit} projectId={projectId} isDraft={isDraft} + moveToIssue={moveToIssue} + modalTitle={modalTitle} + primaryButtonText={primaryButtonText} + isDuplicateModalOpen={isDuplicateModalOpen} + handleDuplicateIssueModal={handleDuplicateIssueModal} + isProjectSelectionDisabled={isProjectSelectionDisabled} /> ); diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx index 35785d348..5f182d24e 100644 --- a/web/core/components/issues/issue-modal/form.tsx +++ b/web/core/components/issues/issue-modal/form.tsx @@ -7,7 +7,7 @@ import { useForm } from "react-hook-form"; // editor import { EditorRefApi } from "@plane/editor"; // types -import type { TIssue, ISearchIssueResponse } from "@plane/types"; +import type { TIssue, ISearchIssueResponse, TWorkspaceDraftIssue } from "@plane/types"; // hooks import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -22,15 +22,18 @@ import { CreateLabelModal } from "@/components/labels"; import { ETabIndices } from "@/constants/tab-indices"; // helpers import { cn } from "@/helpers/common.helper"; +import { getTextContent } from "@/helpers/editor.helper"; import { getChangedIssuefields } from "@/helpers/issue.helper"; import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; -import { useIssueDetail, useProject, useProjectState } from "@/hooks/store"; +import { useIssueDetail, useProject, useProjectState, useWorkspaceDraftIssues } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; // plane web components +import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe"; import { IssueAdditionalProperties, IssueTypeSelect } from "@/plane-web/components/issues/issue-modal"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; const defaultValues: Partial = { project_id: "", @@ -53,18 +56,29 @@ export interface IssueFormProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; projectId: string; isDraft: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (isOpen: boolean) => void; + isProjectSelectionDisabled?: boolean; } export const IssueFormRoot: FC = observer((props) => { const { data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -72,25 +86,42 @@ export const IssueFormRoot: FC = observer((props) => { isCreateMoreToggleEnabled, onCreateMoreToggleChange, isDraft, + moveToIssue, + modalTitle = `${data?.id ? "Update" : isDraft ? "Create a draft" : "Create new issue"}`, + primaryButtonText = { + default: `${data?.id ? "Update" : isDraft ? "Save to Drafts" : "Save"}`, + loading: `${data?.id ? "Updating" : "Saving"}`, + }, + isDuplicateModalOpen, + handleDuplicateIssueModal, + isProjectSelectionDisabled = false, } = props; // states const [labelModal, setLabelModal] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); const [gptAssistantModal, setGptAssistantModal] = useState(false); + const [isMoving, setIsMoving] = useState(false); // refs const editorRef = useRef(null); const submitBtnRef = useRef(null); + const formRef = useRef(null); + const modalContainerRef = useRef(null); // router const { workspaceSlug, projectId: routeProjectId } = useParams(); // store hooks const { getProjectById } = useProject(); - const { getIssueTypeIdOnProjectChange, getActiveAdditionalPropertiesLength, handlePropertyValuesValidation } = - useIssueModal(); + const { + getIssueTypeIdOnProjectChange, + getActiveAdditionalPropertiesLength, + handlePropertyValuesValidation, + handleCreateUpdatePropertyValues, + } = useIssueModal(); const { isMobile } = usePlatformOS(); + const { moveIssue } = useWorkspaceDraftIssues(); const { issue: { getIssueById }, @@ -119,6 +150,9 @@ export const IssueFormRoot: FC = observer((props) => { watch: watch, }); + // derived values + const projectDetails = projectId ? getProjectById(projectId) : undefined; + const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); //reset few fields on projectId change @@ -184,6 +218,7 @@ export const IssueFormRoot: FC = observer((props) => { project_id: getValues<"project_id">("project_id"), id: data.id, description_html: formData.description_html ?? "

", + type_id: getValues<"type_id">("type_id"), }; // this condition helps to move the issues from draft to project issues @@ -206,6 +241,33 @@ export const IssueFormRoot: FC = observer((props) => { }); }; + const handleMoveToProjects = async () => { + if (!data?.id || !data?.project_id || !data) return; + setIsMoving(true); + try { + await handleCreateUpdatePropertyValues({ + issueId: data.id, + issueTypeId: data.type_id, + projectId: data.project_id, + workspaceSlug: workspaceSlug.toString(), + isDraft: true, + }); + + await moveIssue(workspaceSlug.toString(), data.id, { + ...data, + ...getValues(), + } as TWorkspaceDraftIssue); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to move issue to project. Please try again.", + }); + } finally { + setIsMoving(false); + } + }; + const condition = (watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "

"); @@ -216,6 +278,16 @@ export const IssueFormRoot: FC = observer((props) => { else onChange(null); }; + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues( + projectDetails?.workspace.toString(), + projectId ?? undefined, + { + name: watch("name"), + description_html: getTextContent(watch("description_html")), + } + ); + // executing this useEffect when the parent_id coming from the component prop useEffect(() => { const parentId = watch("parent_id") || undefined; @@ -251,6 +323,27 @@ export const IssueFormRoot: FC = observer((props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDirty]); + useEffect(() => { + const formElement = formRef?.current; + const modalElement = modalContainerRef?.current; + + if (!formElement || !modalElement) return; + + const resizeObserver = new ResizeObserver(() => { + modalElement.style.maxHeight = `${formElement?.offsetHeight}px`; + }); + + resizeObserver.observe(formElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [formRef, modalContainerRef]); + + // TODO: Remove this after the de-dupe feature is implemented + + const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0; + return ( <> {projectId && ( @@ -264,178 +357,199 @@ export const IssueFormRoot: FC = observer((props) => { }} /> )} -
handleFormSubmit(data))}> -
-

{data?.id ? "Update" : "Create new"} issue

- {/* Disable project selection if editing an issue */} -
- - {projectId && ( - - )} -
- {watch("parent_id") && selectedParentIssue && ( -
- -
- )} -
- -
-
-
4 && - "max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm" - )} - > -
- - setValue<"description_html">("description_html", description_html) - } - setGptAssistantModal={setGptAssistantModal} - handleGptAssistantClose={() => reset(getValues())} - onClose={onClose} - /> -
-
+
+ handleFormSubmit(data))} + className="flex flex-col w-full" > - {projectId && ( - - )} -
-
-
-
- -
-
- {!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - tabIndex={getIndex("create_more")} - role="button" - > - {}} size="sm" /> - Create more +
+

{modalTitle}

+
+
+ + {projectId && ( + + )} +
+ {duplicateIssues.length > 0 && ( + 1 ? "s" : ""} found!`} + handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)} + /> + )}
- )} -
-
+
4 && + "max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm" + )} + > +
+ + setValue<"description_html">("description_html", description_html) } - }} - tabIndex={getIndex("discard_button")} + setGptAssistantModal={setGptAssistantModal} + handleGptAssistantClose={() => reset(getValues())} + onAssetUpload={onAssetUpload} + onClose={onClose} + /> +
+
- Discard - - {isDraft && ( - <> - {data?.id ? ( + {projectId && ( + + )} +
+
+
+
+ +
+
+ {!data?.id && ( +
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} + onKeyDown={(e) => { + if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); + }} + tabIndex={getIndex("create_more")} + role="button" + > + {}} size="sm" /> + Create more +
+ )} +
+ + + {moveToIssue && ( - ) : ( - )} - - )} - +
+
-
+
- + {shouldRenderDuplicateModal && ( +
+ +
+ )} +
); }); diff --git a/web/core/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/modal.tsx index 9266cb322..3c8a639ee 100644 --- a/web/core/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/modal.tsx @@ -15,11 +15,19 @@ export interface IssuesModalProps { data?: Partial; isOpen: boolean; onClose: () => void; + beforeFormSubmit?: () => Promise; onSubmit?: (res: TIssue) => Promise; withDraftIssueWrapper?: boolean; storeType?: EIssuesStoreType; isDraft?: boolean; fetchIssueDetails?: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; + isProjectSelectionDisabled?: boolean; } export const CreateUpdateIssueModal: React.FC = observer( diff --git a/web/core/components/issues/peek-overview/index.ts b/web/core/components/issues/peek-overview/index.ts index 9cd51648b..3e0d56558 100644 --- a/web/core/components/issues/peek-overview/index.ts +++ b/web/core/components/issues/peek-overview/index.ts @@ -1,5 +1,4 @@ export * from "./header"; -export * from "./issue-attachments"; export * from "./issue-detail"; export * from "./properties"; export * from "./root"; diff --git a/web/core/components/issues/peek-overview/issue-attachments.tsx b/web/core/components/issues/peek-overview/issue-attachments.tsx deleted file mode 100644 index 8ffcdc277..000000000 --- a/web/core/components/issues/peek-overview/issue-attachments.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -// hooks -import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; -import { IssueAttachmentUpload, IssueAttachmentsList, TAttachmentOperations } from "@/components/issues"; -import { useEventTracker, useIssueDetail } from "@/hooks/store"; -// components -// ui - -type Props = { - disabled: boolean; - issueId: string; - projectId: string; - workspaceSlug: string; -}; - -export const PeekOverviewIssueAttachments: React.FC = (props) => { - const { disabled, issueId, projectId, workspaceSlug } = props; - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { - attachment: { createAttachment, removeAttachment }, - } = useIssueDetail(); - - const handleAttachmentOperations: TAttachmentOperations = useMemo( - () => ({ - create: async (data: FormData) => { - try { - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); - setPromiseToast(attachmentUploadPromise, { - loading: "Uploading attachment...", - success: { - title: "Attachment uploaded", - message: () => "The attachment has been successfully uploaded", - }, - error: { - title: "Attachment not uploaded", - message: () => "The attachment could not be uploaded", - }, - }); - - const res = await attachmentUploadPromise; - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: res.id, - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - }); - } - }, - remove: async (attachmentId: string) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); - setToast({ - message: "The attachment has been successfully removed", - type: TOAST_TYPE.SUCCESS, - title: "Attachment removed", - }); - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - setToast({ - message: "The Attachment could not be removed", - type: TOAST_TYPE.ERROR, - title: "Attachment not removed", - }); - } - }, - }), - [workspaceSlug, projectId, issueId, captureIssueEvent, createAttachment, removeAttachment] - ); - - return ( -
-
Attachments
-
- - -
-
- ); -}; diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx index 20117d10f..74aba71fd 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -1,14 +1,19 @@ +"use-client"; import { FC, useEffect } from "react"; import { observer } from "mobx-react"; // components -import { TIssueOperations } from "@/components/issues"; +import { IssueParentDetail, TIssueOperations } from "@/components/issues"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; // store hooks -import { useIssueDetail, useUser } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser } from "@/hooks/store"; // hooks import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // plane web components +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; // local components +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { IssueDescriptionInput } from "../description-input"; import { IssueReaction } from "../issue-detail/reactions"; import { IssueTitleInput } from "../title-input"; @@ -31,6 +36,7 @@ export const PeekOverviewIssueDetails: FC = observer( const { issue: { getIssueById }, } = useIssueDetail(); + const { getProjectById } = useProject(); // hooks const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -45,7 +51,16 @@ export const PeekOverviewIssueDetails: FC = observer( } }, [isSubmitting, setShowAlert, setIsSubmitting]); + // derived values const issue = issueId ? getIssueById(issueId) : undefined; + const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectDetails?.id, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + if (!issue || !issue.project_id) return <>; const issueDescription = @@ -57,7 +72,27 @@ export const PeekOverviewIssueDetails: FC = observer( return (
- + {issue.parent_id && ( + + )} +
+ + {duplicateIssues?.length > 0 && ( + + )} +
= observer((pro Created by
- - {createdByDetails?.display_name} + + + {createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name} +
)} diff --git a/web/core/components/issues/peek-overview/root.tsx b/web/core/components/issues/peek-overview/root.tsx index 7ac5cd50c..70eb51d81 100644 --- a/web/core/components/issues/peek-overview/root.tsx +++ b/web/core/components/issues/peek-overview/root.tsx @@ -1,9 +1,11 @@ "use client"; -import { FC, useEffect, useState, useMemo } from "react"; +import { FC, useEffect, useState, useMemo, useCallback } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; +// plane types import { TIssue } from "@plane/types"; +// plane ui import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; // components import { IssueView, TIssueOperations } from "@/components/issues"; @@ -13,17 +15,17 @@ import { EIssuesStoreType } from "@/constants/issue"; // hooks import { useEventTracker, useIssueDetail, useIssues, useUserPermissions } from "@/hooks/store"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; interface IIssuePeekOverview { embedIssue?: boolean; embedRemoveCurrentNotification?: () => void; - is_archived?: boolean; is_draft?: boolean; } export const IssuePeekOverview: FC = observer((props) => { - const { embedIssue = false, embedRemoveCurrentNotification, is_archived = false, is_draft = false } = props; + const { embedIssue = false, embedRemoveCurrentNotification, is_draft = false } = props; // router const pathname = usePathname(); // store hook @@ -44,31 +46,25 @@ export const IssuePeekOverview: FC = observer((props) => { // state const [error, setError] = useState(false); - const removeRoutePeekId = () => { + const removeRoutePeekId = useCallback(() => { setPeekIssue(undefined); - if (embedIssue) embedRemoveCurrentNotification && embedRemoveCurrentNotification(); - }; + if (embedIssue) embedRemoveCurrentNotification?.(); + }, [embedIssue, embedRemoveCurrentNotification, setPeekIssue]); const issueOperations: TIssueOperations = useMemo( () => ({ - fetch: async (workspaceSlug: string, projectId: string, issueId: string, loader = true) => { + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { setError(false); - await fetchIssue( - workspaceSlug, - projectId, - issueId, - is_archived ? "ARCHIVED" : is_draft ? "DRAFT" : "DEFAULT" - ); - setError(false); + await fetchIssue(workspaceSlug, projectId, issueId, is_draft ? "DRAFT" : "DEFAULT"); } catch (error) { setError(true); - console.error("Error fetching the parent issue"); + console.error("Error fetching the parent issue", error); } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - issues?.updateIssue && - (await issues + if (issues?.updateIssue) { + await issues .updateIssue(workspaceSlug, projectId, issueId, data) .then(async () => { fetchActivities(workspaceSlug, projectId, issueId); @@ -93,7 +89,8 @@ export const IssuePeekOverview: FC = observer((props) => { type: TOAST_TYPE.ERROR, message: "Issue update failed", }); - })); + }); + } }, remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { @@ -105,7 +102,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); removeRoutePeekId(); }); - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -120,13 +117,14 @@ export const IssuePeekOverview: FC = observer((props) => { }, archive: async (workspaceSlug: string, projectId: string, issueId: string) => { try { - issues?.archiveIssue && (await issues.archiveIssue(workspaceSlug, projectId, issueId)); + if (!issues?.archiveIssue) return; + await issues.archiveIssue(workspaceSlug, projectId, issueId); captureIssueEvent({ eventName: ISSUE_ARCHIVED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, path: pathname, }); - } catch (error) { + } catch { captureIssueEvent({ eventName: ISSUE_ARCHIVED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, @@ -147,7 +145,7 @@ export const IssuePeekOverview: FC = observer((props) => { payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, path: pathname, }); - } catch (error) { + } catch { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -173,7 +171,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, path: pathname, }); - } catch (error) { + } catch { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -202,7 +200,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, path: pathname, }); - } catch (error) { + } catch { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -244,7 +242,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, path: pathname, }); - } catch (error) { + } catch { captureIssueEvent({ eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, @@ -307,7 +305,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, path: pathname, }); - } catch (error) { + } catch { captureIssueEvent({ eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, @@ -320,7 +318,7 @@ export const IssuePeekOverview: FC = observer((props) => { } }, }), - [is_archived, is_draft, fetchIssue, issues, restoreIssue, captureIssueEvent, pathname] + [fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue] ); useEffect(() => { @@ -346,7 +344,7 @@ export const IssuePeekOverview: FC = observer((props) => { issueId={peekIssue.issueId} isLoading={getIsFetchingIssueDetails(peekIssue.issueId)} isError={error} - is_archived={is_archived} + is_archived={!!peekIssue.isArchived} disabled={!isEditable} embedIssue={embedIssue} embedRemoveCurrentNotification={embedRemoveCurrentNotification} diff --git a/web/core/components/issues/peek-overview/view.tsx b/web/core/components/issues/peek-overview/view.tsx index 959592fcc..8a9a7f2e2 100644 --- a/web/core/components/issues/peek-overview/view.tsx +++ b/web/core/components/issues/peek-overview/view.tsx @@ -66,7 +66,7 @@ export const IssueView: FC = observer((props) => { // remove peek id const removeRoutePeekId = () => { setPeekIssue(undefined); - if (embedIssue) embedRemoveCurrentNotification && embedRemoveCurrentNotification(); + if (embedIssue && embedRemoveCurrentNotification) embedRemoveCurrentNotification(); }; const isLocalDBIssueDescription = getIsLocalDBIssueDescription(issueId); diff --git a/web/core/components/issues/relations/issue-list-item.tsx b/web/core/components/issues/relations/issue-list-item.tsx index 20bf1e2f0..9ac1253ae 100644 --- a/web/core/components/issues/relations/issue-list-item.tsx +++ b/web/core/components/issues/relations/issue-list-item.tsx @@ -3,7 +3,8 @@ import React, { FC } from "react"; import { observer } from "mobx-react"; import { X, Pencil, Trash, Link as LinkIcon } from "lucide-react"; -import { TIssue, TIssueRelationTypes } from "@plane/types"; +// Plane +import { TIssue } from "@plane/types"; import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; // components import { RelationIssueProperty } from "@/components/issues/relations"; @@ -13,7 +14,8 @@ import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-red import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; -// types +import { TIssueRelationTypes } from "@/plane-web/types"; +// import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper"; type Props = { diff --git a/web/core/components/issues/relations/issue-list.tsx b/web/core/components/issues/relations/issue-list.tsx index 5f63dd454..1b89788a4 100644 --- a/web/core/components/issues/relations/issue-list.tsx +++ b/web/core/components/issues/relations/issue-list.tsx @@ -1,10 +1,13 @@ "use client"; import React, { FC } from "react"; import { observer } from "mobx-react"; -import { TIssue, TIssueRelationTypes } from "@plane/types"; +// Plane +import { TIssue } from "@plane/types"; // components import { RelationIssueListItem } from "@/components/issues/relations"; -// types +// Plane-web +import { TIssueRelationTypes } from "@/plane-web/types"; +// import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper"; type Props = { diff --git a/web/core/components/issues/workspace-draft/delete-modal.tsx b/web/core/components/issues/workspace-draft/delete-modal.tsx new file mode 100644 index 000000000..9eefe0d03 --- /dev/null +++ b/web/core/components/issues/workspace-draft/delete-modal.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +// types +import { TWorkspaceDraftIssue } from "@plane/types"; +// ui +import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +// constants +import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; +// hooks +import { useIssues, useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +type Props = { + isOpen: boolean; + handleClose: () => void; + dataId?: string | null | undefined; + data?: TWorkspaceDraftIssue; + onSubmit?: () => Promise; +}; + +export const WorkspaceDraftIssueDeleteIssueModal: React.FC = (props) => { + const { dataId, data, isOpen, handleClose, onSubmit } = props; + // states + const [isDeleting, setIsDeleting] = useState(false); + // store hooks + const { issueMap } = useIssues(); + const { allowPermissions } = useUserPermissions(); + + const { data: currentUser } = useUser(); + + // derived values + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + useEffect(() => { + setIsDeleting(false); + }, [isOpen]); + + if (!dataId && !data) return null; + + // derived values + const issue = data ? data : issueMap[dataId!]; + const isIssueCreator = issue?.created_by === currentUser?.id; + const authorized = isIssueCreator || canPerformProjectAdminActions; + + const onClose = () => { + setIsDeleting(false); + handleClose(); + }; + + const handleIssueDelete = async () => { + setIsDeleting(true); + + if (!authorized) { + setToast({ + title: PROJECT_ERROR_MESSAGES.permissionError.title, + type: TOAST_TYPE.ERROR, + message: PROJECT_ERROR_MESSAGES.permissionError.message, + }); + onClose(); + return; + } + if (onSubmit) + await onSubmit() + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: `draft deleted.`, + }); + onClose(); + }) + .catch((errors) => { + const isPermissionError = errors?.error === "Only admin or creator can delete the issue"; + const currentError = isPermissionError + ? PROJECT_ERROR_MESSAGES.permissionError + : PROJECT_ERROR_MESSAGES.issueDeleteError; + setToast({ + title: currentError.title, + type: TOAST_TYPE.ERROR, + message: currentError.message, + }); + }) + .finally(() => onClose()); + }; + + return ( + Are you sure you want to delete this draft? This can't be undone.} + /> + ); +}; diff --git a/web/core/components/issues/workspace-draft/draft-issue-block.tsx b/web/core/components/issues/workspace-draft/draft-issue-block.tsx new file mode 100644 index 000000000..d1ae6e01c --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-block.tsx @@ -0,0 +1,198 @@ +"use client"; +import React, { FC, useRef, useState } from "react"; +import { omit } from "lodash"; +import { observer } from "mobx-react"; +import { Copy, Pencil, SquareStackIcon, Trash2 } from "lucide-react"; +// types +import { TWorkspaceDraftIssue } from "@plane/types"; +// ui +import { Row, TContextMenuItem, Tooltip } from "@plane/ui"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// helper +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme, useProject, useWorkspaceDraftIssues } from "@/hooks/store"; +// plane-web components +import { IdentifierText, IssueTypeIdentifier } from "@/plane-web/components/issues"; +// local components +import { WorkspaceDraftIssueQuickActions } from "../issue-layouts"; +import { CreateUpdateIssueModal } from "../issue-modal"; +import { WorkspaceDraftIssueDeleteIssueModal } from "./delete-modal"; +import { DraftIssueProperties } from "./draft-issue-properties"; + +type Props = { + workspaceSlug: string; + issueId: string; +}; + +export const DraftIssueBlock: FC = observer((props) => { + // props + const { workspaceSlug, issueId } = props; + // states + const [moveToIssue, setMoveToIssue] = useState(false); + const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState(undefined); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + // hooks + const { getIssueById, updateIssue, deleteIssue } = useWorkspaceDraftIssues(); + const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); + const { getProjectIdentifierById } = useProject(); + // ref + const issueRef = useRef(null); + // derived values + const issue = getIssueById(issueId); + const projectIdentifier = (issue && issue.project_id && getProjectIdentifierById(issue.project_id)) || undefined; + if (!issue || !projectIdentifier) return null; + + const duplicateIssuePayload = omit( + { + ...issue, + name: `${issue.name} (copy)`, + is_draft: true, + }, + ["id"] + ); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setCreateUpdateIssueModal(true); + }, + }, + { + key: "move-to-issues", + title: "Move to project", + icon: SquareStackIcon, + action: () => { + setMoveToIssue(true); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setDeleteIssueModal(true); + }, + }, + ]; + + return ( + <> + setDeleteIssueModal(false)} + onSubmit={async () => deleteIssue(workspaceSlug, issueId)} + /> + { + setCreateUpdateIssueModal(false); + setIssueToEdit(undefined); + setMoveToIssue(false); + }} + data={issueToEdit ?? duplicateIssuePayload} + onSubmit={async (data) => { + if (issueToEdit) await updateIssue(workspaceSlug, issueId, data); + }} + storeType={EIssuesStoreType.WORKSPACE_DRAFT} + fetchIssueDetails={false} + moveToIssue={moveToIssue} + isDraft + /> +
{ + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > + +
+
+
+
+ {issue.project_id && ( +
+ {issue?.type_id && } + +
+ )} +
+ + {/* sub-issues chevron */} +
+
+ + +

{issue.name}

+
+
+ + {/* quick actions */} +
+ +
+
+ +
+ { + await updateIssue(workspaceSlug, issueId, data); + }} + /> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
+ +
+ + ); +}); diff --git a/web/core/components/issues/workspace-draft/draft-issue-properties.tsx b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx new file mode 100644 index 000000000..156e3c730 --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { CalendarCheck2, CalendarClock } from "lucide-react"; +// types +import { TIssuePriorities, TWorkspaceDraftIssue } from "@plane/types"; +// components +import { + DateDropdown, + EstimateDropdown, + PriorityDropdown, + MemberDropdown, + ModuleDropdown, + CycleDropdown, + StateDropdown, +} from "@/components/dropdowns"; +// helpers +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { useLabel, useProjectState, useProject, useProjectEstimates, useWorkspaceDraftIssues } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// local components +import { IssuePropertyLabels } from "../issue-layouts"; + +export interface IIssueProperties { + issue: TWorkspaceDraftIssue; + updateIssue: + | ((projectId: string | null, issueId: string, data: Partial) => Promise) + | undefined; + className: string; +} + +export const DraftIssueProperties: React.FC = observer((props) => { + const { issue, updateIssue, className } = props; + // store hooks + const { getProjectById } = useProject(); + const { labelMap } = useLabel(); + const { addCycleToIssue, addModulesToIssue } = useWorkspaceDraftIssues(); + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const { getStateById } = useProjectState(); + const { isMobile } = usePlatformOS(); + const projectDetails = getProjectById(issue.project_id); + + // router + const { workspaceSlug } = useParams(); + // derived values + const stateDetails = getStateById(issue.state_id); + + const issueOperations = useMemo( + () => ({ + updateIssueModules: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.id) return; + await addModulesToIssue(workspaceSlug.toString(), issue.id, moduleIds); + }, + addIssueToCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.id) return; + await addCycleToIssue(workspaceSlug.toString(), issue.id, cycleId); + }, + removeIssueFromCycle: async () => { + if (!workspaceSlug || !issue.id) return; + // TODO: To be checked + await addCycleToIssue(workspaceSlug.toString(), issue.id, ""); + }, + }), + [workspaceSlug, issue, addCycleToIssue, addModulesToIssue] + ); + + const handleState = (stateId: string) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { state_id: stateId }); + + const handlePriority = (value: TIssuePriorities) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { priority: value }); + + const handleLabel = (ids: string[]) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { label_ids: ids }); + + const handleAssignee = (ids: string[]) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { assignee_ids: ids }); + + const handleModule = useCallback( + (moduleIds: string[] | null) => { + if (!issue || !issue.module_ids || !moduleIds) return; + issueOperations.updateIssueModules(moduleIds); + }, + [issueOperations, issue] + ); + + const handleCycle = useCallback( + (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + if (cycleId) issueOperations.addIssueToCycle?.(cycleId); + else issueOperations.removeIssueFromCycle?.(); + }, + [issue, issueOperations] + ); + + const handleStartDate = (date: Date | null) => + issue?.project_id && + updateIssue && + updateIssue(issue.project_id, issue.id, { + start_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined, + }); + + const handleTargetDate = (date: Date | null) => + issue?.project_id && + updateIssue && + updateIssue(issue.project_id, issue.id, { + target_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined, + }); + + const handleEstimate = (value: string | undefined) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { estimate_point: value }); + + if (!issue.project_id) return null; + + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; + + const minDate = getDate(issue.start_date); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + + const handleEventPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( +
+ {/* basic properties */} + {/* state */} +
+ +
+ + {/* priority */} +
+ +
+ + {/* label */} + +
+ +
+ + {/* start date */} +
+ } + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-10" + renderByDefault={isMobile} + showTooltip + /> +
+ + {/* target/due date */} +
+ } + buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + buttonClassName={ + shouldHighlightIssueDueDate(issue?.target_date || null, stateDetails?.group) ? "text-red-500" : "" + } + clearIconClassName="!text-custom-text-100" + optionsClassName="z-10" + renderByDefault={isMobile} + showTooltip + /> +
+ + {/* assignee */} +
+ 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} + showTooltip={issue?.assignee_ids?.length === 0} + placeholder="Assignees" + optionsClassName="z-10" + tooltipContent="" + renderByDefault={isMobile} + /> +
+ + {/* modules */} + {projectDetails?.module_view && ( +
+ +
+ )} + + {/* cycles */} + {projectDetails?.cycle_view && ( +
+ +
+ )} + + {/* estimates */} + {issue.project_id && areEstimateEnabledByProjectId(issue.project_id?.toString()) && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/issues/workspace-draft/empty-state.tsx b/web/core/components/issues/workspace-draft/empty-state.tsx new file mode 100644 index 000000000..4a1292d61 --- /dev/null +++ b/web/core/components/issues/workspace-draft/empty-state.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { FC, Fragment, useState } from "react"; +// components +import { EmptyState } from "@/components/empty-state"; +import { CreateUpdateIssueModal } from "@/components/issues"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { EIssuesStoreType } from "@/constants/issue"; + +export const WorkspaceDraftEmptyState: FC = () => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + + return ( + + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ { + setIsDraftIssueModalOpen(true); + }} + /> +
+
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/index.ts b/web/core/components/issues/workspace-draft/index.ts new file mode 100644 index 000000000..07138bc0b --- /dev/null +++ b/web/core/components/issues/workspace-draft/index.ts @@ -0,0 +1,4 @@ +export * from "./draft-issue-block"; +export * from "./draft-issue-properties"; +export * from "./delete-modal"; +export * from "./root"; diff --git a/web/core/components/issues/workspace-draft/loader.tsx b/web/core/components/issues/workspace-draft/loader.tsx new file mode 100644 index 000000000..d663a0d03 --- /dev/null +++ b/web/core/components/issues/workspace-draft/loader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FC } from "react"; +// components +import { ListLoaderItemRow } from "@/components/ui"; + +type TWorkspaceDraftIssuesLoader = { + items?: number; +}; + +export const WorkspaceDraftIssuesLoader: FC = (props) => { + const { items = 14 } = props; + return ( +
+ {[...Array(items)].map((_, index) => ( + + ))} +
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/quick-action.tsx b/web/core/components/issues/workspace-draft/quick-action.tsx new file mode 100644 index 000000000..884e81a91 --- /dev/null +++ b/web/core/components/issues/workspace-draft/quick-action.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export interface Props { + parentRef: React.RefObject; + MENU_ITEMS: TContextMenuItem[]; +} + +export const WorkspaceDraftIssueQuickActions: React.FC = observer((props) => { + const { parentRef, MENU_ITEMS } = props; + + return ( + <> + + + {MENU_ITEMS.map((item) => ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+ + ); +}); diff --git a/web/core/components/issues/workspace-draft/root.tsx b/web/core/components/issues/workspace-draft/root.tsx new file mode 100644 index 000000000..177b8af55 --- /dev/null +++ b/web/core/components/issues/workspace-draft/root.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { EmptyState } from "@/components/empty-state"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { EDraftIssuePaginationType } from "@/constants/workspace-drafts"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useCommandPalette, useProject, useWorkspaceDraftIssues } from "@/hooks/store"; +import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; +// components +import { DraftIssueBlock } from "./draft-issue-block"; +import { WorkspaceDraftEmptyState } from "./empty-state"; +import { WorkspaceDraftIssuesLoader } from "./loader"; + +type TWorkspaceDraftIssuesRoot = { + workspaceSlug: string; +}; + +export const WorkspaceDraftIssuesRoot: FC = observer((props) => { + const { workspaceSlug } = props; + // hooks + const { loader, paginationInfo, fetchIssues, issueIds } = useWorkspaceDraftIssues(); + const { workspaceProjectIds } = useProject(); + const { toggleCreateProjectModal } = useCommandPalette(); + + //swr hook for fetching issue properties + useWorkspaceIssueProperties(workspaceSlug); + + // fetching issues + const { isLoading } = useSWR( + workspaceSlug && issueIds.length <= 0 ? `WORKSPACE_DRAFT_ISSUES_${workspaceSlug}` : null, + workspaceSlug && issueIds.length <= 0 ? async () => await fetchIssues(workspaceSlug, "init-loader") : null + ); + + // handle nest issues + const handleNextIssues = async () => { + if (!paginationInfo?.next_page_results) return; + await fetchIssues(workspaceSlug, "pagination", EDraftIssuePaginationType.NEXT); + }; + + if (isLoading) { + return ; + } + + if (workspaceProjectIds?.length === 0) + return ( + { + toggleCreateProjectModal(true); + }} + /> + ); + + if (issueIds.length <= 0) return ; + + return ( +
+
+ {issueIds.map((issueId: string) => ( + + ))} +
+ + {paginationInfo?.next_page_results && ( + + {loader === "pagination" && issueIds.length >= 0 ? ( + + ) : ( +
+ Load More ↓ +
+ )} +
+ )} +
+ ); +}); diff --git a/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/web/core/components/modules/analytics-sidebar/progress-stats.tsx index 712928f14..3fc7849a4 100644 --- a/web/core/components/modules/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const ModuleProgressStats: FC = observer((props) => ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/modules/applied-filters/members.tsx b/web/core/components/modules/applied-filters/members.tsx index 69f7d0004..ccb8c90c9 100644 --- a/web/core/components/modules/applied-filters/members.tsx +++ b/web/core/components/modules/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && (
); -}); \ No newline at end of file +}); diff --git a/web/core/components/modules/module-list-item.tsx b/web/core/components/modules/module-list-item.tsx index 64627b2ab..64dfe7452 100644 --- a/web/core/components/modules/module-list-item.tsx +++ b/web/core/components/modules/module-list-item.tsx @@ -13,11 +13,9 @@ import { ModuleListItemAction, ModuleQuickActions } from "@/components/modules"; // helpers import { generateQueryParams } from "@/helpers/router.helper"; // hooks -import { useModule, useProjectEstimates } from "@/hooks/store"; +import { useModule } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web constants -import { EEstimateSystem } from "@/plane-web/constants/estimates"; type Props = { moduleId: string; @@ -35,27 +33,14 @@ export const ModuleListItem: React.FC = observer((props) => { // store hooks const { getModuleById } = useModule(); const { isMobile } = usePlatformOS(); - const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); // derived values const moduleDetails = getModuleById(moduleId); if (!moduleDetails) return null; - /** - * NOTE: This completion percentage calculation is based on the total issues count. - * when estimates are available and estimate type is points, we should consider the estimate point count - * when estimates are available and estimate type is not points, then by default we consider the issue count - */ - const isEstimateEnabled = - projectId && - currentActiveEstimateId && - areEstimateEnabledByProjectId(projectId?.toString()) && - estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS; - - const completionPercentage = isEstimateEnabled - ? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100 - : ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; + const completionPercentage = + ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); diff --git a/web/core/components/onboarding/create-or-join-workspaces.tsx b/web/core/components/onboarding/create-or-join-workspaces.tsx index 3bfc71a2b..f295a1d5c 100644 --- a/web/core/components/onboarding/create-or-join-workspaces.tsx +++ b/web/core/components/onboarding/create-or-join-workspaces.tsx @@ -4,11 +4,14 @@ import Image from "next/image"; // icons import { useTheme } from "next-themes"; // types +import { OctagonAlert } from "lucide-react"; import { IWorkspaceMemberInvitation, TOnboardingSteps } from "@plane/types"; // components import { Invitations, OnboardingHeader, SwitchAccountDropdown, CreateWorkspace } from "@/components/onboarding"; // hooks import { useUser } from "@/hooks/store"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; // assets import CreateJoinWorkspaceDark from "@/public/onboarding/create-join-workspace-dark.webp"; import CreateJoinWorkspace from "@/public/onboarding/create-join-workspace-light.webp"; @@ -34,6 +37,8 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { const { data: user } = useUser(); // hooks const { resolvedTheme } = useTheme(); + // derived values + const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false; useEffect(() => { if (invitations.length > 0) { @@ -66,12 +71,25 @@ export const CreateOrJoinWorkspaces: React.FC = observer((props) => { handleCurrentViewChange={() => setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE)} /> ) : currentView === ECreateOrJoinWorkspaceViews.WORKSPACE_CREATE ? ( - setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)} - /> + isWorkspaceCreationEnabled ? ( + setCurrentView(ECreateOrJoinWorkspaceViews.WORKSPACE_JOIN)} + /> + ) : ( +
+
+ + + You don't seem to have any invites to a workspace and your instance admin has restricted + creation of new workspaces. Please ask a workspace owner or admin to invite you to a workspace first + and come back to this screen to join. + +
+
+ ) ) : (
diff --git a/web/core/components/onboarding/create-workspace.tsx b/web/core/components/onboarding/create-workspace.tsx index d9d789ede..56cbb14e3 100644 --- a/web/core/components/onboarding/create-workspace.tsx +++ b/web/core/components/onboarding/create-workspace.tsx @@ -3,13 +3,14 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; +// constants +import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; // types import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // ui import { Button, CustomSelect, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { E_ONBOARDING, WORKSPACE_CREATED } from "@/constants/event-tracker"; -import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace"; // hooks import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store"; // services @@ -154,18 +155,19 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="name" > - Workspace name + Name your workspace - /^[\w\s-]*$/.test(value) || `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`, + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, maxLength: { value: 80, - message: "Workspace name should not exceed 80 characters", + message: "Limit your name to 80 characters.", }, }} render={({ field: { value, ref, onChange } }) => ( @@ -182,7 +184,7 @@ export const CreateWorkspace: React.FC = observer((props) => { shouldValidate: true, }); }} - placeholder="Enter workspace name..." + placeholder="Something familiar and recognizable is always best." ref={ref} hasError={Boolean(errors.name)} className="w-full border-onboarding-border-100 placeholder:text-custom-text-400" @@ -198,16 +200,16 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="slug" > - Workspace URL + Set your workspace's URL ( @@ -223,20 +225,22 @@ export const CreateWorkspace: React.FC = observer((props) => { type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - /^[a-zA-Z0-9_-]+$/.test(e.target.value) ? setInvalidSlug(false) : setInvalidSlug(true); + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} ref={ref} hasError={Boolean(errors.slug)} + placeholder="workspace-name" className="w-full border-none !px-0" />
)} />

You can only edit the slug of the URL

- {slugError &&

Workspace URL is already taken!

} + {slugError &&

This URL is taken. Try something else.

} {invalidSlug && ( -

{`URL can only contain ( - ), ( _ ) & alphanumeric characters.`}

+

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

)} {errors.slug && {errors.slug.message}}
@@ -246,20 +250,20 @@ export const CreateWorkspace: React.FC = observer((props) => { className="text-sm text-onboarding-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" htmlFor="organization_size" > - Company size + How many people will use this workspace?
( c === value) ?? ( - Select organization size + Select a range ) } buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md" diff --git a/web/core/components/onboarding/profile-setup.tsx b/web/core/components/onboarding/profile-setup.tsx index 7685276b7..fee8dead9 100644 --- a/web/core/components/onboarding/profile-setup.tsx +++ b/web/core/components/onboarding/profile-setup.tsx @@ -17,22 +17,22 @@ import { OnboardingHeader, SwitchAccountDropdown } from "@/components/onboarding // constants import { USER_DETAILS, E_ONBOARDING_STEP_1, E_ONBOARDING_STEP_2 } from "@/constants/event-tracker"; // helpers +import { getFileURL } from "@/helpers/file.helper"; import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useEventTracker, useUser, useUserProfile } from "@/hooks/store"; -// services // assets import ProfileSetupDark from "@/public/onboarding/profile-setup-dark.webp"; import ProfileSetupLight from "@/public/onboarding/profile-setup-light.webp"; import UserPersonalizationDark from "@/public/onboarding/user-personalization-dark.webp"; import UserPersonalizationLight from "@/public/onboarding/user-personalization-light.webp"; +// services import { AuthService } from "@/services/auth.service"; -import { FileService } from "@/services/file.service"; type TProfileSetupFormValues = { first_name: string; last_name: string; - avatar?: string | null; + avatar_url?: string | null; password?: string; confirm_password?: string; role?: string; @@ -42,7 +42,7 @@ type TProfileSetupFormValues = { const defaultValues: Partial = { first_name: "", last_name: "", - avatar: "", + avatar_url: "", password: undefined, confirm_password: undefined, role: undefined, @@ -77,7 +77,6 @@ const USER_DOMAIN = [ "Other", ]; -const fileService = new FileService(); const authService = new AuthService(); export const ProfileSetup: React.FC = observer((props) => { @@ -86,7 +85,6 @@ export const ProfileSetup: React.FC = observer((props) => { const [profileSetupStep, setProfileSetupStep] = useState( user?.is_password_autoset ? EProfileSetupSteps.USER_DETAILS : EProfileSetupSteps.ALL ); - const [isRemoving, setIsRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [showPassword, setShowPassword] = useState({ @@ -112,10 +110,12 @@ export const ProfileSetup: React.FC = observer((props) => { ...defaultValues, first_name: user?.first_name, last_name: user?.last_name, - avatar: user?.avatar, + avatar_url: user?.avatar_url, }, mode: "onChange", }); + // derived values + const userAvatar = watch("avatar_url"); const handleShowPassword = (key: keyof typeof showPassword) => setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); @@ -129,7 +129,7 @@ export const ProfileSetup: React.FC = observer((props) => { const userDetailsPayload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, + avatar_url: formData.avatar_url ?? undefined, }; const profileUpdatePayload: Partial = { use_case: formData.use_case, @@ -173,7 +173,7 @@ export const ProfileSetup: React.FC = observer((props) => { const userDetailsPayload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, + avatar_url: formData.avatar_url ?? undefined, }; try { await Promise.all([ @@ -240,12 +240,7 @@ export const ProfileSetup: React.FC = observer((props) => { const handleDelete = (url: string | null | undefined) => { if (!url) return; - - setIsRemoving(true); - fileService.deleteUserFile(url).finally(() => { - setValue("avatar", ""); - setIsRemoving(false); - }); + setValue("avatar_url", ""); }; // derived values @@ -302,13 +297,12 @@ export const ProfileSetup: React.FC = observer((props) => { <> ( setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(getValues("avatar"))} + handleRemove={async () => handleDelete(getValues("avatar_url"))} onSuccess={(url) => { onChange(url); setIsImageUploadModalOpen(false); @@ -319,7 +313,7 @@ export const ProfileSetup: React.FC = observer((props) => { />
+
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+ + + ); +}); + +ColorDropdown.displayName = "ColorDropdown"; diff --git a/web/core/components/pages/editor/header/index.ts b/web/core/components/pages/editor/header/index.ts index 219ed44d8..d87f5d119 100644 --- a/web/core/components/pages/editor/header/index.ts +++ b/web/core/components/pages/editor/header/index.ts @@ -1,3 +1,4 @@ +export * from "./color-dropdown"; export * from "./extra-options"; export * from "./info-popover"; export * from "./options-dropdown"; diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index 0560002d8..c7cf53a5f 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -1,12 +1,15 @@ "use client"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useRouter } from "next/navigation"; -import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; +import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; // document editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // ui import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ExportPageModal } from "@/components/pages"; // helpers import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper"; // hooks @@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const router = useRouter(); // store values const { + name, archived_at, is_locked, id, @@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC = observer((props) => { canCurrentUserLockPage, restore, } = page; + // states + const [isExportModalOpen, setIsExportModalOpen] = useState(false); // store hooks const { workspaceSlug, projectId } = useParams(); // page filters @@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC = observer((props) => { icon: History, shouldRender: true, }, + { + key: "export", + action: () => setIsExportModalOpen(true), + label: "Export", + icon: ArrowUpToLine, + shouldRender: true, + }, ]; return ( - - handleFullWidth(!isFullWidth)} - > - Full width - {}} /> - - {MENU_ITEMS.map((item) => { - if (!item.shouldRender) return null; - return ( - - - {item.label} - - ); - })} - + <> + setIsExportModalOpen(false)} + pageTitle={name ?? ""} + /> + + handleFullWidth(!isFullWidth)} + > + Full width + {}} /> + + {MENU_ITEMS.map((item) => { + if (!item.shouldRender) return null; + return ( + + + {item.label} + + ); + })} + + ); }); diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 65d484ef1..6e4ffdd5f 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -3,9 +3,11 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi } from "@plane/editor"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; +// components +import { ColorDropdown } from "@/components/pages"; // constants import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers @@ -18,7 +20,7 @@ type Props = { type ToolbarButtonProps = { item: ToolbarMenuItem; isActive: boolean; - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: EditorRefApi["executeMenuItemCommand"]; }; const ToolbarButton: React.FC = React.memo((props) => { @@ -34,9 +36,15 @@ const ToolbarButton: React.FC = React.memo((props) => { } >